Explorar o código

feat: 完善 IP 操盘 V1 核心闭环

Yi Jiarui hai 2 meses
pai
achega
824732f0c5
Modificáronse 62 ficheiros con 25781 adicións e 1699 borrados
  1. 64 0
      e2e/ip-operator-account-workbench.spec.ts
  2. 56 0
      e2e/ip-operator-assistant-governance.spec.ts
  3. 158 0
      e2e/ip-operator-evidence-loop.spec.ts
  4. 33 0
      e2e/ip-operator-evidence-workbench.spec.ts
  5. 16 114
      e2e/ip-operator-p2-core.spec.ts
  6. 72 0
      e2e/ip-operator-production-loop.spec.ts
  7. 66 114
      e2e/ip-operator-workbench.spec.ts
  8. 6 19
      src/app/app.html
  9. 58 2
      src/app/app.spec.ts
  10. 101 10
      src/app/app.ts
  11. 2 2
      src/app/components/app-sidebar/app-sidebar.component.ts
  12. 949 1
      src/app/models/ip-operator.model.ts
  13. 4355 642
      src/app/pages/ip-operator/ip-operator.component.css
  14. 1287 633
      src/app/pages/ip-operator/ip-operator.component.html
  15. 2083 19
      src/app/pages/ip-operator/ip-operator.component.spec.ts
  16. 4433 101
      src/app/pages/ip-operator/ip-operator.component.ts
  17. 158 0
      src/app/services/ip-account-diagnosis.service.spec.ts
  18. 604 0
      src/app/services/ip-account-diagnosis.service.ts
  19. 711 0
      src/app/services/ip-account-monitor.service.spec.ts
  20. 894 0
      src/app/services/ip-account-monitor.service.ts
  21. 143 0
      src/app/services/ip-account-reference-work-analysis.service.spec.ts
  22. 329 0
      src/app/services/ip-account-reference-work-analysis.service.ts
  23. 947 0
      src/app/services/ip-account-strategy-orchestrator.service.spec.ts
  24. 1364 0
      src/app/services/ip-account-strategy-orchestrator.service.ts
  25. 235 0
      src/app/services/ip-account-transcript-orchestrator.service.spec.ts
  26. 217 0
      src/app/services/ip-account-transcript-orchestrator.service.ts
  27. 219 0
      src/app/services/ip-account-work-analysis.service.spec.ts
  28. 300 0
      src/app/services/ip-account-work-analysis.service.ts
  29. 239 0
      src/app/services/ip-account-work-media-analysis.service.spec.ts
  30. 463 0
      src/app/services/ip-account-work-media-analysis.service.ts
  31. 156 0
      src/app/services/ip-account-work-report.service.spec.ts
  32. 180 0
      src/app/services/ip-account-work-report.service.ts
  33. 107 0
      src/app/services/ip-content-production-flow.service.spec.ts
  34. 221 0
      src/app/services/ip-content-production-flow.service.ts
  35. 54 0
      src/app/services/ip-operator-assistant-orchestrator.service.spec.ts
  36. 83 0
      src/app/services/ip-operator-assistant-orchestrator.service.ts
  37. 57 0
      src/app/services/ip-operator-comment-label.service.spec.ts
  38. 143 0
      src/app/services/ip-operator-comment-label.service.ts
  39. 32 0
      src/app/services/ip-operator-comment-pain.service.spec.ts
  40. 219 0
      src/app/services/ip-operator-comment-pain.service.ts
  41. 43 0
      src/app/services/ip-operator-evidence-adapter.spec.ts
  42. 196 0
      src/app/services/ip-operator-evidence-adapter.ts
  43. 74 0
      src/app/services/ip-operator-evidence.service.spec.ts
  44. 82 2
      src/app/services/ip-operator-evidence.service.ts
  45. 6 0
      src/app/services/ip-operator-flags.ts
  46. 297 11
      src/app/services/ip-operator-prompts.ts
  47. 44 0
      src/app/services/ip-operator-publish-package.service.spec.ts
  48. 300 0
      src/app/services/ip-operator-publish-package.service.ts
  49. 135 0
      src/app/services/ip-publish-package-refiner.service.spec.ts
  50. 241 0
      src/app/services/ip-publish-package-refiner.service.ts
  51. 289 0
      src/app/services/ip-publish-retrospective-loop.service.spec.ts
  52. 388 0
      src/app/services/ip-publish-retrospective-loop.service.ts
  53. 73 0
      src/app/services/ip-script-workbench.service.spec.ts
  54. 150 0
      src/app/services/ip-script-workbench.service.ts
  55. 217 0
      src/app/services/ip-topic-script-generator.service.spec.ts
  56. 316 0
      src/app/services/ip-topic-script-generator.service.ts
  57. 105 0
      src/app/services/reference-video-prompt-planner.service.spec.ts
  58. 273 0
      src/app/services/reference-video-prompt-planner.service.ts
  59. 109 0
      src/app/services/retrospective.service.spec.ts
  60. 90 6
      src/app/services/retrospective.service.ts
  61. 252 17
      src/app/services/viral-analysis.service.spec.ts
  62. 287 6
      src/app/services/viral-analysis.service.ts

+ 64 - 0
e2e/ip-operator-account-workbench.spec.ts

@@ -0,0 +1,64 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'empty' | 'ready' | 'needs_input' | 'failed') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+
+async function seedScenario(page: Page, scenario: 'ready' | 'needs_input' = 'ready') {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript((nextScenario) => {
+    window.__seedIpOperatorScenario?.(nextScenario);
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  }, scenario);
+}
+
+test('account-driven workbench connects account evidence, tasks, scripts, and publish binding', async ({ page }) => {
+  await seedScenario(page, 'ready');
+  await page.goto('/');
+
+  await expect(page.locator('.account-workbench')).toBeVisible();
+  await expect(page.locator('.operator-cockpit')).toContainText('账号运营驾驶舱');
+  await expect(page.locator('.operator-cockpit__status')).toContainText('林川老板增长笔记');
+  await expect(page.locator('.operator-cockpit__status')).toContainText('数据诊断');
+  await expect(page.locator('.priority-task-board')).toContainText('今天必须做');
+  await expect(page.locator('.priority-task-board')).toContainText('本周要做');
+  await expect(page.locator('.account-monitor-panel')).toContainText('账号内容与数据');
+  await expect(page.locator('.account-monitor-panel')).toContainText('重点证据作品');
+  const firstWork = page.locator('.account-monitor-work-list article').first();
+  await expect(firstWork).toBeVisible();
+  await expect(page.getByText('装了很多,收藏了很多,但真正开工的时候还是不知道该先用哪个。')).toHaveCount(0);
+  await expect(page.locator('.publish-binding-panel').first()).toContainText('发布包绑定');
+
+  await page.getByRole('button', { name: /方向选题/ }).click();
+  const cardsNeedingScript = page.locator('.direction-topic-card').filter({
+    has: page.getByRole('button', { name: '生成分镜脚本' }),
+  });
+  const firstTopicCard = (await cardsNeedingScript.count()) ? cardsNeedingScript.first() : page.locator('.direction-topic-card').first();
+  const scriptAction = firstTopicCard.getByRole('button', { name: /生成分镜脚本|分镜脚本已生成/ });
+  const hadGeneratedScript = (await scriptAction.textContent())?.includes('分镜脚本已生成') || false;
+  await scriptAction.click();
+  if (hadGeneratedScript) {
+    await expect(page.locator('.shot-script-modal')).toBeVisible();
+  } else {
+    await expect(firstTopicCard.getByRole('button', { name: '分镜脚本已生成' })).toBeVisible();
+    await firstTopicCard.getByRole('button', { name: '分镜脚本已生成' }).click();
+  }
+  await expect(page.locator('.shot-script-modal')).toContainText('秒');
+  await expect(page.locator('.shot-script-modal')).toContainText('开头钩子');
+  await page.locator('.shot-script-modal__close').click();
+
+  await firstTopicCard.getByRole('button', { name: '生成发布包' }).click();
+  await expect(page.locator('.workspace-tabs button.is-active')).toContainText('发布复盘');
+  await expect(page.locator('.publish-binding-panel').first()).toContainText('发布包绑定');
+
+  await expect.poll(async () => page.evaluate(() => {
+    const raw = localStorage.getItem('tiktok.ipOperator.plan.ip_plan_e2e_ready.local-guest');
+    const plan = raw ? JSON.parse(raw) : null;
+    return (plan?.publishPackages || []).length;
+  })).toBeGreaterThan(0);
+});

+ 56 - 0
e2e/ip-operator-assistant-governance.spec.ts

@@ -0,0 +1,56 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'ready') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+
+async function seedReadyScenario(page: Page, assistantAction?: Record<string, unknown>) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript((action) => {
+    window.__seedIpOperatorScenario?.('ready');
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+    if (action) {
+      sessionStorage.setItem('videoWorkflow.ipOperator.assistantAction', JSON.stringify(action));
+    }
+  }, assistantAction || null);
+}
+
+test('assistant medium-risk action enters confirmation queue', async ({ page }) => {
+  await seedReadyScenario(page, {
+    action: 'approve_publish_package',
+    label: '批准发布包',
+    reason: '会影响发布决策,需要人工确认。',
+  });
+  await page.goto('/');
+
+  await expect(page.locator('.assistant-confirm-panel')).toBeVisible();
+  await expect(page.locator('.assistant-confirm-panel')).toContainText('批准发布包');
+  await expect(page.locator('.assistant-confirm-panel')).toContainText('确认执行');
+});
+
+test('assistant blocked automation action is rejected', async ({ page }) => {
+  await seedReadyScenario(page, {
+    action: 'auto_comment',
+    label: '自动评论',
+    reason: '涉及平台互动自动化。',
+  });
+  await page.goto('/');
+
+  await expect(page.locator('.assistant-confirm-panel__blocked')).toBeVisible();
+  await expect(page.locator('.assistant-confirm-panel__blocked')).toContainText('禁止执行');
+});
+
+test('matrix governance remains available while scheduled monitor placeholder is removed', async ({ page }) => {
+  await seedReadyScenario(page);
+  await page.goto('/');
+
+  await expect(page.locator('.monitor-schedule-panel')).toHaveCount(0);
+  await expect(page.locator('.legacy-plan-archive')).toHaveCount(0);
+  await expect(page.locator('.account-manager-panel')).toContainText('运营账号生命周期');
+  await expect(page.locator('.account-workbench')).toContainText('发布复盘');
+});

+ 158 - 0
e2e/ip-operator-evidence-loop.spec.ts

@@ -0,0 +1,158 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'ready') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+
+async function seedEvidenceLoop(page: Page) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(() => {
+    window.__seedIpOperatorScenario?.('ready');
+    const scope = 'local-guest';
+    const planId = 'ip_plan_e2e_ready';
+    const accountId = 'ip_account_e2e_ready';
+    const now = new Date().toISOString();
+    const ownedWorkId = 'evidence_owned_work';
+    const commentId = 'evidence_owned_comment';
+    const benchmarkId = 'evidence_benchmark_work';
+    const painId = 'pain_evidence_loop';
+    const planKey = `tiktok.ipOperator.plan.${planId}.${scope}`;
+    const plan = JSON.parse(localStorage.getItem(planKey) || '{}');
+    plan.evidenceItems = [
+      {
+        id: ownedWorkId,
+        platform: 'douyin',
+        kind: 'owned_post',
+        bucket: 'accounts',
+        accountId,
+        sourceRole: 'owned',
+        title: '自有账号作品:老板 IP 如何避免成为广告号',
+        rawText: '先讲客户判断,再讲产品。',
+        summary: '播放 50000,评论 120,收藏 380。',
+        tags: ['自有账号', '账号作品'],
+        relatedTopicIds: [],
+        relatedScriptIds: [],
+        sourceId: 'work-owned-1',
+        evidenceRefs: ['https://www.douyin.com/video/7304943423281171749'],
+        selectedForGeneration: true,
+        confidence: 'high',
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: commentId,
+        platform: 'douyin',
+        kind: 'comment',
+        bucket: 'topics',
+        accountId,
+        sourceRole: 'owned',
+        title: '自有账号评论:老板 IP 如何避免成为广告号',
+        rawText: '传统行业老板怎么讲案例才不像广告?',
+        summary: '传统行业老板怎么讲案例才不像广告?',
+        tags: ['自有账号', '评论痛点'],
+        relatedTopicIds: [],
+        relatedScriptIds: [],
+        sourceId: 'comment-owned-1',
+        evidenceRefs: [],
+        selectedForGeneration: true,
+        confidence: 'high',
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: benchmarkId,
+        platform: 'douyin',
+        kind: 'competitor_post',
+        bucket: 'accounts',
+        sourceRole: 'benchmark',
+        title: '对标作品:先讲判断标准再讲方法',
+        rawText: '反常识开头,案例证明。',
+        summary: '可借鉴问题钩子和案例结构。',
+        tags: ['对标账号'],
+        relatedTopicIds: [],
+        relatedScriptIds: [],
+        sourceId: 'benchmark-work-1',
+        evidenceRefs: [],
+        selectedForGeneration: true,
+        confidence: 'medium',
+        createdAt: now,
+        updatedAt: now,
+      },
+    ];
+    plan.commentPainInsights = [{
+      id: painId,
+      title: '用户追问:怎么讲案例',
+      category: 'question',
+      severity: 'high',
+      summary: '评论集中追问老板如何讲案例而不显得像广告。',
+      commentCount: 8,
+      keywordFrequency: [{ keyword: '怎么', count: 8 }],
+      representativeComments: ['传统行业老板怎么讲案例才不像广告?'],
+      evidenceItemIds: [commentId],
+      suggestedTopics: ['案例表达三步法'],
+      scriptAngles: ['用评论原话开头'],
+      riskBoundaries: ['不承诺结果'],
+      createdAt: now,
+      updatedAt: now,
+    }];
+    plan.accountStrategyReports = [{
+      id: 'strategy_evidence_loop',
+      accountId,
+      snapshotId: 'ip_snapshot_e2e_ready',
+      positioningProposalId: 'ip_positioning_proposal_e2e_ready',
+      sourceMode: 'llm',
+      sourceLabel: 'LLM 证据诊断',
+      diagnosisSummary: {},
+      evidenceRefs: [],
+      directions: [],
+      taskSuggestions: [],
+      createdAt: now,
+      updatedAt: now,
+    }];
+    plan.topics = (plan.topics || []).map((topic: Record<string, unknown>, index: number) => ({
+      ...topic,
+      evidenceRefs: index === 0 ? [ownedWorkId, commentId] : [ownedWorkId],
+    }));
+    localStorage.setItem(planKey, JSON.stringify(plan));
+
+    const directionIndexKey = `tiktok.ipOperator.contentDirections.${scope}`;
+    for (const directionId of JSON.parse(localStorage.getItem(directionIndexKey) || '[]')) {
+      const key = `tiktok.ipOperator.contentDirection.${directionId}.${scope}`;
+      const direction = JSON.parse(localStorage.getItem(key) || '{}');
+      direction.sourceEvidenceIds = [ownedWorkId, benchmarkId];
+      direction.commentPainRefs = [painId];
+      localStorage.setItem(key, JSON.stringify(direction));
+    }
+    const taskIndexKey = `tiktok.ipOperator.operationTasks.${scope}`;
+    for (const taskId of JSON.parse(localStorage.getItem(taskIndexKey) || '[]')) {
+      const key = `tiktok.ipOperator.operationTask.${taskId}.${scope}`;
+      const task = JSON.parse(localStorage.getItem(key) || '{}');
+      task.relatedEvidenceIds = [ownedWorkId, commentId];
+      localStorage.setItem(key, JSON.stringify(task));
+    }
+    const proposalKey = `tiktok.ipOperator.positioningProposal.ip_positioning_proposal_e2e_ready.${scope}`;
+    const proposal = JSON.parse(localStorage.getItem(proposalKey) || '{}');
+    proposal.evidenceItemIds = [ownedWorkId, commentId];
+    localStorage.setItem(proposalKey, JSON.stringify(proposal));
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  });
+}
+
+test('IP operator shows traceable evidence drawer for direction conclusions', async ({ page }) => {
+  await seedEvidenceLoop(page);
+  await page.goto('/');
+
+  await expect(page.getByRole('heading', { name: 'IP操盘工作台' })).toBeVisible();
+  await page.getByRole('button', { name: /方向选题/ }).click();
+  await page.getByRole('button', { name: '查看方向证据' }).first().click();
+  const drawer = page.getByRole('complementary', { name: '证据来源' });
+  await expect(drawer).toBeVisible();
+  await expect(drawer.getByText('自有账号作品').first()).toBeVisible();
+  await expect(drawer.getByText('评论痛点').first()).toBeVisible();
+  await expect(drawer.getByText('对标作品').first()).toBeVisible();
+});

+ 33 - 0
e2e/ip-operator-evidence-workbench.spec.ts

@@ -0,0 +1,33 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'ready') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+
+async function seedReadyScenario(page: Page) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(() => {
+    window.__seedIpOperatorScenario?.('ready');
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  });
+}
+
+test('evidence workspace exposes account works and comments without legacy V1 forms', async ({ page }) => {
+  await seedReadyScenario(page);
+  await page.goto('/');
+
+  await expect(page.locator('.ip-operator-page')).toBeVisible();
+  await page.getByRole('button', { name: '证据 作品与评论' }).click();
+
+  await expect(page.locator('.account-monitor-panel')).toContainText('账号作品与评论样本');
+  await expect(page.locator('.account-monitor-work-list article').first()).toBeVisible();
+  await expect(page.locator('.account-monitor-work-list')).toContainText('评论');
+  await expect(page.getByRole('button', { name: '保存证据' })).toHaveCount(0);
+  await expect(page.getByRole('button', { name: '分析痛点' })).toHaveCount(0);
+  await expect(page.locator('.legacy-plan-archive')).toHaveCount(0);
+});

+ 16 - 114
e2e/ip-operator-p2-core.spec.ts

@@ -9,134 +9,36 @@ declare global {
 
 const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
 const planKey = 'tiktok.ipOperator.plan.ip_plan_e2e_ready.local-guest';
-const viralAnalysisKey = 'videoWorkflow.viralAnalyses.items';
 
 async function seedP2Scenario(page: Page) {
   await page.addInitScript({ path: scenarioSeedPath });
-  await page.addInitScript(([nextPlanKey, nextViralAnalysisKey]) => {
-    const now = new Date().toISOString();
+  await page.addInitScript(() => {
     window.__seedIpOperatorScenario?.('ready');
     localStorage.setItem('tiktok.currentTab', 'ip-operator');
-
-    const raw = localStorage.getItem(nextPlanKey);
-    if (!raw) return;
-    const plan = JSON.parse(raw);
-    plan.benchmarkEvidence = [{
-      id: 'ip_evidence_e2e_existing',
-      planId: plan.id,
-      profileId: plan.profileId,
-      benchmarkId: 'benchmark_e2e_1',
-      source: 'viral_analysis',
-      sourceId: 'analysis_e2e_existing',
-      awemeId: '7123456789012345678',
-      inputUrl: 'https://www.douyin.com/video/7123456789012345678',
-      title: 'E2E benchmark comment evidence',
-      authorName: 'Benchmark Owner Account',
-      summary: 'Users reacted to the account because it named the trust problem before selling.',
-      hookPattern: 'Question hook + hard contrast',
-      reusableFrame: 'Name the hidden trust problem, then replace the case with this IP assets.',
-      commentPainPoints: ['Looks like an ad account', 'No real case proof', 'Unsure whether the owner understands my problem'],
-      migrationSuggestion: 'Use the comment pain to build a trust-diagnosis opportunity, then rewrite topics and scripts around one real owner scenario.',
-      nonCopyableRisk: 'Do not copy the benchmark title, exact wording, claimed result, or industry-specific case.',
-      evidenceRefs: ['comment: Looks like an ad account', 'comment: No real case proof'],
-      confidence: 'high',
-      createdAt: now,
-    }];
-    plan.evidenceFetchJobs = [];
-    plan.evidenceSuggestions = [];
-    localStorage.setItem(nextPlanKey, JSON.stringify(plan));
-
-    localStorage.setItem(nextViralAnalysisKey, JSON.stringify([{
-      id: 'analysis_e2e_saved',
-      userId: 'local-guest',
-      awemeId: '7987654321098765432',
-      source: 'manual',
-      videoSnapshot: {
-        awemeId: '7987654321098765432',
-        desc: 'E2E saved viral analysis',
-        authorName: 'Saved Benchmark',
-        diggCount: 12000,
-        commentCount: 800,
-        shareCount: 450,
-      },
-      commentsSnapshot: [
-        { id: 'c1', text: 'This feels useful because it does not sell first.', likeCount: 99 },
-        { id: 'c2', text: 'I want to see a real before-after case.', likeCount: 80 },
-      ],
-      confidence: 'medium',
-      analysis: {
-        summary: 'Saved analysis highlights trust-first owner content.',
-        hookType: 'pain question',
-        openingPattern: 'Ask why users distrust owner accounts',
-        contentRhythm: 'hook-proof-action',
-        conflict: 'selling first vs earning trust first',
-        proofPoint: 'real case detail',
-        audienceEmotion: 'skeptical but interested',
-        commentTrigger: 'asks for before-after examples',
-        reusableFrame: 'replace the case with this IP own client story',
-        reusableAngles: ['trust diagnosis', 'case proof'],
-        riskNotes: ['avoid copying exact claim'],
-        evidenceRefs: ['c1', 'c2'],
-      },
-      savedTopicIds: [],
-      createdAt: now,
-      updatedAt: now,
-    }]));
-  }, [planKey, viralAnalysisKey]);
+  });
 }
 
-test('P2 core links benchmark evidence back to opportunities, topics, scripts, and production', async ({ page }) => {
+test('P2 core keeps account evidence connected to direction topics and production actions', async ({ page }) => {
   await seedP2Scenario(page);
   await page.goto('/');
 
   await expect(page.locator('.ip-operator-page')).toBeVisible();
-  await expect(page.locator('.ip-quality-audit')).toBeVisible();
-  await expect(page.locator('.ip-evidence-panel')).toBeVisible();
-  await expect(page.getByText('E2E benchmark comment evidence')).toBeVisible();
+  await expect(page.locator('.account-monitor-panel')).toContainText('账号内容与数据');
 
-  await page.locator('.ip-evidence-grid section').first().locator('article button').first().click();
-  await expect(page.locator('.ip-evidence-suggestions article')).toHaveCount(3);
-  await page.locator('.ip-evidence-suggestions article .btn-primary').first().click();
+  await page.getByRole('button', { name: /方向选题/ }).click();
+  const firstTopicCard = page.locator('.direction-topic-card').first();
+  await expect(firstTopicCard).toBeVisible();
+  await expect(firstTopicCard).toContainText('来源证据');
 
-  await expect.poll(async () => page.evaluate((nextPlanKey) => {
-    const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
-    return Boolean(
-      plan.evidenceSuggestions?.some((item: { status?: string }) => item.status === 'applied')
-      && plan.opportunityMap?.some((item: { type?: string; reason?: string }) => (
-        item.type === '评论痛点迁移' && String(item.reason || '').includes('Looks like an ad account')
-      ))
-    );
-  }, planKey)).toBe(true);
+  await firstTopicCard.getByRole('button', { name: /生成分镜脚本|分镜脚本已生成/ }).click();
+  await expect(page.locator('.shot-script-modal')).toBeVisible();
+  await expect(page.locator('.shot-script-modal')).toContainText('核心观点');
+  await page.locator('.shot-script-modal__close').click();
 
-  await page.locator('.ip-evidence-form').first().locator('input').fill('https://v.douyin.com/short-only/');
-  await page.locator('.ip-evidence-form').first().locator('button').click();
+  await firstTopicCard.getByRole('button', { name: '生成发布包' }).click();
+  await expect(page.locator('.workspace-tabs button.is-active')).toContainText('发布复盘');
   await expect.poll(async () => page.evaluate((nextPlanKey) => {
     const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
-    return plan.evidenceFetchJobs?.some((job: { status?: string; failureCode?: string }) => (
-      job.status === 'failed' && job.failureCode === 'invalid_url'
-    ));
-  }, planKey)).toBe(true);
-
-  const savedForm = page.locator('.ip-evidence-form--saved');
-  await savedForm.locator('button').first().click();
-  await savedForm.locator('select').selectOption('analysis_e2e_saved');
-  await savedForm.locator('button').nth(1).click();
-  await expect.poll(async () => page.evaluate((nextPlanKey) => {
-    const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
-    return Boolean(
-      plan.benchmarkEvidence?.some((item: { sourceId?: string }) => item.sourceId === 'analysis_e2e_saved')
-      && plan.evidenceFetchJobs?.some((job: { analysisId?: string; status?: string }) => (
-        job.analysisId === 'analysis_e2e_saved' && job.status === 'completed'
-      ))
-    );
-  }, planKey)).toBe(true);
-
-  await page.locator('.ip-result-tabs button').nth(1).click();
-  await expect(page.locator('.ip-topic-card').first()).toBeVisible();
-
-  await page.locator('.ip-result-tabs button').nth(2).click();
-  await expect(page.locator('.ip-script-detail').first()).toBeVisible();
-  await page.locator('.ip-script-detail').first().locator('.btn-primary').click();
-  await expect(page.locator('.dh-page')).toBeVisible();
-  await expect(page.locator('textarea.dh-textarea').first()).not.toHaveValue('');
+    return (plan.publishPackages || []).length;
+  }, planKey)).toBeGreaterThan(0);
 });

+ 72 - 0
e2e/ip-operator-production-loop.spec.ts

@@ -0,0 +1,72 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'ready') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+const planKey = 'tiktok.ipOperator.plan.ip_plan_e2e_ready.local-guest';
+
+async function seedReadyScenario(page: Page) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(() => {
+    window.__seedIpOperatorScenario?.('ready');
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  });
+}
+
+test('direction topic can generate script, calendar item, publish package, and review task', async ({ page }) => {
+  await seedReadyScenario(page);
+  await page.goto('/');
+
+  await expect(page.locator('.account-workbench')).toBeVisible();
+  await expect(page.locator('.content-direction-grid .direction-topic-card').nth(1)).toBeVisible();
+
+  const topicCard = page.locator('.content-direction-grid .direction-topic-card').nth(1);
+  const topicTitle = (await topicCard.locator('strong').textContent())?.trim() || '';
+  expect(topicTitle.length).toBeGreaterThan(0);
+
+  const scriptAction = topicCard.getByRole('button', { name: /生成分镜脚本|分镜脚本已生成/ });
+  const scriptLabel = (await scriptAction.textContent()) || '';
+  await scriptAction.click();
+  if (!scriptLabel.includes('分镜脚本已生成')) {
+    await expect(topicCard.getByRole('button', { name: '分镜脚本已生成' })).toBeVisible();
+    await topicCard.getByRole('button', { name: '分镜脚本已生成' }).click();
+  }
+  await expect(page.locator('.shot-script-modal')).toContainText('秒');
+  await expect(page.locator('.shot-script-modal')).toContainText('开头钩子');
+  await page.locator('.shot-script-modal__close').click();
+
+  await topicCard.getByRole('button', { name: '加入内容日历' }).click();
+  await expect.poll(async () => page.evaluate(({ key, title }) => {
+    const plan = JSON.parse(localStorage.getItem(key) || '{}');
+    return Boolean((plan.contentCalendar || []).some((item: { title?: string }) => item.title === title));
+  }, { key: planKey, title: topicTitle })).toBe(true);
+
+  await topicCard.getByRole('button', { name: '生成发布包' }).click();
+  await expect(page.getByRole('heading', { name: '发布前人工审核资料包' })).toBeVisible();
+  await expect(page.getByText('不做自动发布、批量评论、Cookie 池或代理池')).toBeVisible();
+
+  await expect.poll(async () => page.evaluate(({ key, title }) => {
+    const plan = JSON.parse(localStorage.getItem(key) || '{}');
+    const pkg = (plan.publishPackages || []).find((item: { titleOptions?: string[] }) => (item.titleOptions || []).includes(title));
+    const calendar = (plan.contentCalendar || []).find((item: { title?: string }) => item.title === title);
+    return Boolean(pkg && calendar?.publishPackageId === pkg.id && (pkg.riskChecklist || []).some((item: { label?: string }) => item.label?.includes('不自动发布')));
+  }, { key: planKey, title: topicTitle })).toBe(true);
+
+  const packageCard = page.locator('.ip-workbench-card--wide').filter({ hasText: topicTitle }).first();
+  await packageCard.locator('.publish-binding-panel--inline input').fill('https://www.douyin.com/video/7390000001');
+  await packageCard.getByRole('button', { name: '手动绑定作品' }).click();
+
+  await expect.poll(async () => page.evaluate(() => {
+    const ids = JSON.parse(localStorage.getItem('tiktok.ipOperator.operationTasks.local-guest') || '[]');
+    return ids.some((id: string) => {
+      const raw = localStorage.getItem(`tiktok.ipOperator.operationTask.${id}.local-guest`);
+      const task = raw ? JSON.parse(raw) : null;
+      return task?.type === 'review' && String(task?.title || '').includes('复盘');
+    });
+  })).toBe(true);
+});

+ 66 - 114
e2e/ip-operator-workbench.spec.ts

@@ -17,136 +17,88 @@ async function seedScenario(page: Page, scenario: 'empty' | 'ready' | 'needs_inp
   }, [scenario, startTab]);
 }
 
-test('sidebar entry and wizard form reach generation confirmation', async ({ page }) => {
+test('sidebar entry opens the account-driven workbench without legacy V1 tools', async ({ page }) => {
   await seedScenario(page, 'empty', 'home');
   await page.goto('/');
 
-  await page.getByRole('button', { name: /IP操盘/ }).click();
+  await page.getByRole('button', { name: /^IP操盘\s*V1$/ }).click();
+
   await expect(page.locator('.ip-operator-page')).toBeVisible();
-  await expect(page.locator('.ip-card h2')).toContainText('创建 IP 档案');
-
-  const profileCard = page.locator('.ip-card').first();
-  await profileCard.locator('input').nth(0).fill('E2E New Profile');
-  await profileCard.locator('input').nth(1).fill('Local growth consultant');
-  await profileCard.locator('input').nth(2).fill('Local service');
-  await profileCard.locator('input').nth(3).fill('Traditional business owners');
-  await profileCard.locator('input').nth(4).fill('IP diagnosis and content operation');
-  await profileCard.locator('textarea').nth(0).fill('Content looks like advertising');
-  await profileCard.locator('textarea').nth(1).fill('Served local stores with short-video conversion');
-  await profileCard.locator('textarea').nth(2).fill('Positioning, topics, scripts');
-  await profileCard.locator('textarea').nth(5).fill('Generate a usable 7-day test plan');
-  await profileCard.locator('.btn-primary').click();
-
-  const questionnaireCard = page.locator('.ip-card').first();
-  await expect(questionnaireCard.locator('h2')).toContainText('轻问卷');
-  await questionnaireCard.locator('textarea').nth(0).fill('They want trust but dislike hard selling.');
-  await questionnaireCard.locator('textarea').nth(1).fill('I can turn cases into practical content.');
-  await questionnaireCard.locator('textarea').nth(2).fill('I have real local-service experience.');
-  await questionnaireCard.locator('.btn-primary').click();
-
-  const benchmarkCard = page.locator('.ip-card').first();
-  await expect(benchmarkCard.locator('h2')).toContainText('对标账号');
-  await benchmarkCard.locator('input').nth(0).fill('Benchmark Owner Account');
-  await benchmarkCard.locator('input').nth(2).fill('Why owner content fails to convert');
-  await benchmarkCard.locator('textarea').nth(1).fill('It uses direct pain-point diagnosis.');
-  await benchmarkCard.locator('.btn-primary').click();
-
-  const generateCard = page.locator('.ip-card').first();
-  await expect(generateCard.locator('h2')).toContainText('确认生成范围');
-  await expect(generateCard.locator('.ip-generate-scope span')).toHaveCount(9);
-  await expect(generateCard.locator('.btn-primary')).toBeEnabled();
+  await expect(page.locator('.account-workbench')).toBeVisible();
+  await expect(page.locator('.operator-cockpit')).toContainText('账号运营驾驶舱');
+  await expect(page.locator('.legacy-plan-archive')).toHaveCount(0);
+  await expect(page.locator('.low-frequency-panel')).toHaveCount(0);
+  await expect(page.getByRole('button', { name: '新建档案' })).toHaveCount(0);
+  await expect(page.getByText('创建 IP 档案')).toHaveCount(0);
 });
 
-test('needs_input plan blocks production entry points in the browser', async ({ page }) => {
-  await seedScenario(page, 'needs_input');
+test('ready scenario keeps account evidence, directions and publish review in the current workbench', async ({ page }) => {
+  await seedScenario(page, 'ready');
   await page.goto('/');
 
-  await expect(page.locator('.ip-result')).toBeVisible();
-  await expect(page.locator('.ip-readiness-panel.is-blocked')).toBeVisible();
-  await expect(page.locator('.ip-summary-card').filter({ hasText: '64/100' })).toBeVisible();
-  await expect(page.locator('.ip-result-hero .ip-actions button').nth(1)).toBeDisabled();
-  await expect(page.locator('.ip-result-hero .ip-actions .btn-primary')).toBeDisabled();
+  await expect(page.locator('.account-workbench')).toContainText('账号运营驾驶舱');
+  await expect(page.locator('.account-monitor-panel')).toContainText('账号内容与数据');
+  await expect(page.locator('.priority-task-board')).toContainText('今天必须做');
+
+  await page.getByRole('button', { name: /方向选题/ }).click();
+  await expect(page.locator('.direction-workspace')).toBeVisible();
+  await expect(page.locator('.direction-topic-card').first()).toBeVisible();
+
+  const firstTopicCard = page.locator('.direction-topic-card').first();
+  await firstTopicCard.getByRole('button', { name: /生成分镜脚本|分镜脚本已生成/ }).click();
+  await expect(page.locator('.shot-script-modal')).toBeVisible();
+  await expect(page.locator('.shot-script-modal')).toContainText('开头钩子');
+  await page.locator('.shot-script-modal__close').click();
+
+  await firstTopicCard.getByRole('button', { name: /生成并查看发布包|查看发布包/ }).click();
+  await expect(page.locator('.publish-package-modal')).toBeVisible();
+  await expect(page.locator('.publish-package-modal')).toContainText('标题候选');
+  await expect(page.locator('.publish-package-modal')).toContainText('风险检查');
+  await page.locator('.publish-package-modal__close').click({ force: true });
+  await expect(page.locator('.publish-binding-panel').first()).toContainText('发布复盘:把真实表现反哺下一轮');
+  await expect(page.locator('.publish-binding-panel').first()).toContainText('已绑定,待复盘');
 });
 
-test('topic and script edits persist userEdited markers and audit trail', async ({ page }) => {
+test('ready scenario exposes the full calibration and retrospective loop without storage internals', async ({ page }) => {
   await seedScenario(page, 'ready');
   await page.goto('/');
 
-  await page.locator('.ip-result-tabs button').nth(1).click();
-  const firstTopicTitle = page.locator('.ip-topic-card input').first();
-  await firstTopicTitle.fill('Edited owner IP topic title');
-  await firstTopicTitle.press('Tab');
-
-  await expect.poll(async () => page.evaluate(() => {
-    const planId = 'ip_plan_e2e_ready';
-    const raw = localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`);
-    const plan = raw ? JSON.parse(raw) : null;
-    return plan?.topics?.some((topic: { title: string; userEdited?: boolean }) => (
-      topic.title === 'Edited owner IP topic title' && topic.userEdited === true
-    ));
-  })).toBe(true);
-  await expect(page.locator('.ip-topic-card').first()).toContainText('已手动编辑');
-
-  await page.locator('.ip-result-tabs button').nth(2).click();
-  const firstScriptHook = page.locator('.ip-script-detail input').first();
-  await firstScriptHook.fill('Edited script hook for browser validation');
-  await firstScriptHook.press('Tab');
-
-  await expect.poll(async () => page.evaluate(() => {
-    const planId = 'ip_plan_e2e_ready';
-    const raw = localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`);
-    const plan = raw ? JSON.parse(raw) : null;
-    return plan?.scripts?.some((script: { hook: string; userEdited?: boolean }) => (
-      script.hook === 'Edited script hook for browser validation' && script.userEdited === true
-    ));
-  })).toBe(true);
-
-  const auditCount = await page.evaluate(() => {
-    const raw = localStorage.getItem('tiktok.ipOperator.auditTrail.local-guest') || '[]';
-    return JSON.parse(raw).length;
-  });
-  expect(auditCount).toBeGreaterThanOrEqual(3);
+  await expect(page.locator('.account-lifecycle-card')).toContainText('当前阶段');
+  await expect(page.locator('.account-lifecycle-card')).toContainText('下一步');
+  await expect(page.locator('.strategy-report-meta')).toContainText('判断置信度');
+  await expect(page.locator('.strategy-report-meta')).toContainText('中');
+  await expect(page.locator('.strategy-report-meta')).toContainText('已采用校准');
+  await expect(page.locator('.strategy-report-meta')).toContainText('仍缺发布后复盘数据的长期对比');
+  await expect(page.locator('.account-calibration-panel')).toContainText('减少纯工具合集');
+  await expect(page.locator('.publish-retrospective-summary')).toContainText('符合预期');
+  await expect(page.locator('.publish-retrospective-summary')).toContainText('下一步:继续放大');
+
+  const pageText = await page.locator('.account-workbench').textContent();
+  expect(pageText).not.toContain('Parse');
+  expect(pageText).not.toContain('VideoWorkflowEntity');
+  expect(pageText).not.toContain('entityType');
+  expect(pageText).not.toContain('evidenceRefs');
+
+  await page.getByLabel('定位/关注理由修正').fill('继续强调老板经营问题,不再泛讲工具清单');
+  await page.getByLabel('不要再推荐的话题').fill('纯工具合集, 泛 AI 工具盘点');
+  await page.getByLabel('不要使用的表达').fill('保姆级万能');
+  await page.getByLabel('老号经验补充').fill('案例拆解更容易带来收藏和有效评论');
+  await page.getByRole('button', { name: '保存账号校准' }).click();
+
+  await expect(page.locator('.calibration-summary')).toContainText('继续强调老板经营问题');
+
+  await page.getByRole('button', { name: /方向选题/ }).click();
+  await expect(page.locator('.direction-workspace')).toBeVisible();
+  await page.getByRole('button', { name: /总览/ }).click();
+  await expect(page.locator('.calibration-summary')).toContainText('继续强调老板经营问题');
 });
 
-test('failed plan still renders diagnostics instead of a blank page', async ({ page }) => {
+test('failed scenario still renders the account cockpit instead of a blank legacy page', async ({ page }) => {
   await seedScenario(page, 'failed');
   await page.goto('/');
 
-  await expect(page.locator('.ip-result')).toBeVisible();
-  await expect(page.locator('.ip-summary-card').filter({ hasText: '生成失败' })).toBeVisible();
-  await page.locator('.ip-debug-panel details summary').click();
-  await expect(page.locator('.ip-debug-panel')).toContainText('llm_malformed_json');
-  await expect(page.locator('.ip-debug-panel')).toContainText('Malformed JSON in diagnosis stage');
-});
-
-test('ready plan syncs high-priority topics and enters topic-to-video', async ({ page }) => {
-  await seedScenario(page, 'ready');
-  await page.goto('/');
-
-  await expect(page.locator('.ip-operator-page h1')).toContainText('IP操盘工作台');
-  await expect(page.locator('.ip-readiness-panel')).toContainText('可进入生产链路');
-  await expect(page.locator('.ip-summary-card').filter({ hasText: '88/100' })).toBeVisible();
-
-  await page.locator('.ip-result-hero .ip-actions .btn-primary').click();
-  await expect.poll(async () => {
-    return page.evaluate(() => {
-      const items = JSON.parse(localStorage.getItem('videoWorkflow.topicPool.items') || '[]');
-      return items.filter((item: { sourceType?: string }) => item.sourceType === 'ip_operator').length;
-    });
-  }).toBe(4);
-
-  await page.locator('.ip-result-hero .ip-actions button').nth(1).click();
-  await expect(page.locator('.topic-list')).toBeVisible();
-  await expect(page.getByText('Why owner IP should not look like an ad account')).toBeVisible();
-
-  await page.locator('.topic-pool-toolbar select').first().selectOption('ip_operator');
-  await expect(page.getByText('Why owner IP should not look like an ad account')).toBeVisible();
-  await expect(page.locator('.topic-source-card')).toHaveCount(4);
-
-  await page.locator('.topic-card-actions .btn-primary').first().click();
-  await expect(page.locator('.t2v-page')).toBeVisible();
-  await expect(page.locator('.t2v-brief-card')).toBeVisible();
-
-  const topicValue = await page.locator('textarea.dh-textarea').first().inputValue();
-  expect(topicValue).toMatch(/Why owner IP should not look like an ad account|Owner IP trust topic [2-4]/);
+  await expect(page.locator('.account-workbench')).toBeVisible();
+  await expect(page.locator('.operator-cockpit')).toContainText('账号运营驾驶舱');
+  await expect(page.locator('.legacy-plan-archive')).toHaveCount(0);
+  await expect(page.locator('.account-diagnosis-panel, .operator-cockpit__empty')).toHaveCount(1);
 });

+ 6 - 19
src/app/app.html

@@ -899,7 +899,7 @@
                       (navigateToBatch)="setCurrentTab('batch-production')">
       </app-topic-pool>
 
-      <app-ip-operator *ngIf="currentTab === 'ip-operator'"
+      <app-ip-operator *ngIf="ipOperatorEnabled && currentTab === 'ip-operator'"
                        (navigateToTopicPool)="setCurrentTab('topic-pool')"
                        (navigateToTopicVideo)="setCurrentTab('topic-to-video')"
                        (navigateToDigitalHuman)="onIpOperatorDigitalHuman($event)"
@@ -1075,7 +1075,6 @@
             </span>
           </div>
           <div class="task-recovery-audit__actions">
-            <span class="task-recovery-audit__id">Run {{recoveryRun.runId}}</span>
             <button type="button"
                     class="task-btn task-btn--ghost"
                     *ngIf="isHighlightedRecoveryRun(recoveryRun)"
@@ -1089,16 +1088,13 @@
                  *ngIf="latestStorageAuditEvent as storageEvent"
                  [class.task-recovery-audit--highlight]="showStorageAudit">
           <div class="task-recovery-audit__main">
-            <span class="task-recovery-audit__label">存储审计</span>
-            <strong>最近 {{storageAuditEvents.length}} 条</strong>
+            <span class="task-recovery-audit__label">存储提醒</span>
+            <strong>发现 {{storageAuditEvents.length}} 条本地存储异常</strong>
             <span>
-              {{storageEvent.namespace}} / {{storageEvent.operation}} / {{storageEvent.status}},{{formatRecoveryRunTime(storageEvent.at)}}
+              最近一次发生在 {{formatRecoveryRunTime(storageEvent.at)}}。如任务或素材显示不完整,请刷新页面后重试。
             </span>
           </div>
           <div class="task-recovery-audit__actions">
-            <span class="task-recovery-audit__id" [title]="storageEvent.message || storageEvent.eventId">
-              {{storageEvent.storageKey}}
-            </span>
             <button type="button"
                     class="task-btn task-btn--ghost"
                     *ngIf="showStorageAudit"
@@ -1122,12 +1118,6 @@
 
             </h3>
 
-            <button type="button" class="task-btn task-btn--ghost" (click)="exportP0Diagnostics()">
-
-              导出 P0 诊断
-
-            </button>
-
             <button type="button" class="task-btn task-btn--ghost" *ngIf="tasksFilter" (click)="toggleTasksFilter(tasksFilter)">
 
               清除筛选
@@ -1286,13 +1276,10 @@
 
               <!-- 错误信息 -->
 
-              <div class="task-row__meta" *ngIf="getTaskCostLines(task).length || getTaskExternalIds(task).length || task.refundReason">
+              <div class="task-row__meta" *ngIf="getTaskCostLines(task).length || task.refundReason">
                 <span class="task-row__chip" *ngFor="let line of getTaskCostLines(task)">
                   {{getTaskCostLineText(line)}}
                 </span>
-                <span class="task-row__chip" *ngFor="let item of getTaskExternalIds(task)">
-                  {{item.key}}: {{item.value}}
-                </span>
                 <span class="task-row__chip" *ngIf="task.refundReason">
                   退款原因:{{task.refundReason}}
                 </span>
@@ -1300,7 +1287,7 @@
 
               <div class="task-row__meta" *ngIf="task.steps?.length">
                 <span class="task-row__chip" *ngFor="let step of task.steps">
-                  {{step.label}} · {{step.status}} · {{step.progress || 0}}%
+                  {{step.label}} · {{getTaskStepStatusText(step.status)}} · {{step.progress || 0}}%
                   <ng-container *ngIf="getTaskStepDurationText(step)"> · {{getTaskStepDurationText(step)}}</ng-container>
                 </span>
               </div>

+ 58 - 2
src/app/app.spec.ts

@@ -1,10 +1,22 @@
 import { TestBed } from '@angular/core/testing';
+import { EMPTY } from 'rxjs';
 import { App } from './app';
+import { AuthCreditService } from './services/auth-credit.service';
 
 describe('App', () => {
   beforeEach(async () => {
+    localStorage.clear();
     await TestBed.configureTestingModule({
       imports: [App],
+      providers: [{
+        provide: AuthCreditService,
+        useValue: {
+          isLoggedIn: false,
+          currentUser: null,
+          loginRequired$: EMPTY,
+          session$: EMPTY,
+        },
+      }],
     }).compileComponents();
   });
 
@@ -15,10 +27,54 @@ describe('App', () => {
     expect(app).toBeTruthy();
   });
 
-  it('should render title', async () => {
+  it('should render an application heading', async () => {
     const fixture = TestBed.createComponent(App);
+    fixture.detectChanges();
     await fixture.whenStable();
     const compiled = fixture.nativeElement as HTMLElement;
-    expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Tik-tok');
+    expect(compiled.querySelector('h1')?.textContent?.trim()).toBeTruthy();
   });
+
+  it('keeps local generation tasks visible when legacy task source is empty', () => {
+    const fixture = TestBed.createComponent(App);
+    const app = fixture.componentInstance as any;
+    const task = app.generationTasks.create({
+      title: '等待恢复的图片任务',
+      pipelineId: 'image-generation',
+      operation: 'image-generation',
+    });
+
+    app.activeTasks = app.mergeTaskSources([]);
+
+    expect(app.activeTasks.some((item: any) => item.id === task.id)).toBe(true);
+    expect(app.activeTasks[0].keyword).toBe('等待恢复的图片任务');
+  });
+
+  it('only shows storage audit warnings for fallback or failed records', () => {
+    localStorage.setItem('videoWorkflow.indexedDbJsonStorage.audit.v1', JSON.stringify([
+      {
+        eventId: 'ok',
+        namespace: 'contentAssets',
+        storageKey: 'videoWorkflow.contentAssets.v1',
+        operation: 'write',
+        status: 'success',
+        at: new Date().toISOString(),
+      },
+      {
+        eventId: 'fallback',
+        namespace: 'generationTasks',
+        storageKey: 'videoWorkflow.generationTasks.v1',
+        operation: 'read',
+        status: 'fallback',
+        at: new Date().toISOString(),
+      },
+    ]));
+
+    const fixture = TestBed.createComponent(App);
+    const app = fixture.componentInstance;
+
+    expect(app.storageAuditEvents.length).toBe(1);
+    expect(app.storageAuditEvents[0].eventId).toBe('fallback');
+  });
+
 });

+ 101 - 10
src/app/app.ts

@@ -116,6 +116,10 @@ import type {
   VoiceProfile,
 } from './models/app-shell.model';
 import { AppTab, isAppTab } from './models/app-tab.model';
+import {
+  IP_OPERATOR_ENABLED,
+  isIpOperatorTabEnabled,
+} from './services/ip-operator-flags';
 
 @Component({
 
@@ -216,6 +220,7 @@ export class App {
   private readonly remixVersionMapper = inject(RemixVersionMapperService);
   private readonly historyDetailPresenter = inject(HistoryDetailPresenterService);
   private readonly taskOperations = inject(TaskOperationStateService);
+  readonly ipOperatorEnabled = IP_OPERATOR_ENABLED;
 
 
   protected readonly title = signal('抖音AI视频生成系统');
@@ -1127,10 +1132,8 @@ export class App {
     });
 
     this.generationTasks.tasks$.subscribe((tasks) => {
-      const generated = tasks.map(task => this.generationTaskToLegacyTask(task));
       const manualOnly = this.activeTasks.filter(task => !tasks.some(item => item.id === task.id));
-      this.activeTasks = [...generated, ...manualOnly]
-        .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
+      this.activeTasks = this.mergeTaskSources(manualOnly, tasks);
       this.updateTaskStats();
       this.cdr.markForCheck();
     });
@@ -1600,6 +1603,11 @@ export class App {
         target = aliases[target] || target;
       }
 
+      if (isAppTab(target) && !isIpOperatorTabEnabled(target)) {
+        target = 'home';
+        localStorage.setItem(this.TAB_STORAGE_KEY, target);
+      }
+
       if (isAppTab(target)) {
 
         this.currentTab = target;
@@ -1690,6 +1698,10 @@ export class App {
       tab = this.auth.isLoggedIn ? 'user-center' : 'login';
     }
 
+    if (!isIpOperatorTabEnabled(tab)) {
+      tab = 'home';
+    }
+
     if (!this.auth.isLoggedIn && tab !== 'login' && tab !== 'register') {
       this.authReturnTab = tab;
       tab = 'login';
@@ -1776,14 +1788,46 @@ export class App {
   /** 助手内回复中的「直达 X 模式」按钮被点击 */
   onAssistantSuggestion(s: AssistantSuggestion): void {
     if (!s?.tab) return;
+    if (s.risk === 'blocked') {
+      this.showToast('当前版本不支持自动发布、自动评论、自动私信或任何规避平台风控的批量操作。', 'error', 5000);
+      return;
+    }
+    if (s.risk === 'medium') {
+      const message = s.confirmMessage || '这个动作会影响后续选题、脚本或发布安排,请确认后执行。';
+      if (!confirm(message)) return;
+    }
     if (s.payload?.['saveTopic']) {
       this.saveAssistantSuggestionToTopicPool(s);
       return;
     }
+    if (s.tab === 'ip-operator' && s.payload?.['action']) {
+      this.queueIpOperatorAssistantAction(s);
+      return;
+    }
     // 复用首屏跳转通道:含 payload 时同样走 sessionStorage 预填
     this.onHomeNavigate({ tab: s.tab, payload: s.payload });
   }
 
+  private queueIpOperatorAssistantAction(s: AssistantSuggestion): void {
+    try {
+      sessionStorage.setItem('videoWorkflow.ipOperator.assistantAction', JSON.stringify({
+        ...s.payload,
+        label: s.label,
+        createdAt: new Date().toISOString(),
+      }));
+    } catch {}
+    this.setCurrentTab('ip-operator');
+    const action = String(s.payload?.['action'] || '');
+    const message = action === 'create_operation_task'
+      ? '已交给 IP 操盘工作台创建运营任务。'
+      : action === 'draft_calendar'
+        ? '已交给 IP 操盘工作台加入内容日历草稿。'
+        : action === 'generate_script'
+          ? '已交给 IP 操盘工作台生成分镜脚本。'
+          : '已打开 IP 操盘工作台处理该运营动作。';
+    this.showToast(message, 'success', 3000);
+  }
+
   private saveAssistantSuggestionToTopicPool(s: AssistantSuggestion): void {
     if (!this.auth.isLoggedIn) {
       this.auth.requestLogin('保存选题');
@@ -1947,12 +1991,14 @@ export class App {
 
       next: (tasks) => {
 
-        this.activeTasks = this.generationTasks.normalizeLegacyTasks(tasks.map(t => ({
+        const legacyTasks = this.generationTasks.normalizeLegacyTasks(tasks.map(t => ({
           ...t,
           created_at: new Date(t.created_at),
           updated_at: new Date(t.updated_at),
         })) as Task[]);
 
+        this.activeTasks = this.mergeTaskSources(legacyTasks);
+
         this.updateTaskStats();
 
         console.log(`📋 已加载 ${tasks.length} 个任务`);
@@ -1969,7 +2015,7 @@ export class App {
 
         console.warn('⚠️ 无法从后端加载任务,使用空列表');
 
-        this.activeTasks = this.generationTasks.tasks.map(task => this.generationTaskToLegacyTask(task));
+        this.activeTasks = this.mergeTaskSources([]);
         this.updateTaskStats();
 
         this.refreshView();
@@ -2195,7 +2241,9 @@ export class App {
   }
 
   get storageAuditEvents(): IndexedDbJsonStorageAuditEvent[] {
-    return IndexedDbJsonStorage.getAuditTrail(5);
+    return IndexedDbJsonStorage.getAuditTrail()
+      .filter(event => event.status === 'fallback' || event.status === 'failed')
+      .slice(0, 5);
   }
 
   get latestStorageAuditEvent(): IndexedDbJsonStorageAuditEvent | null {
@@ -2446,6 +2494,17 @@ export class App {
     return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
   }
 
+  getTaskStepStatusText(status: string): string {
+    switch (status) {
+      case 'pending': return '等待';
+      case 'running': return '处理中';
+      case 'completed': return '完成';
+      case 'failed': return '失败';
+      case 'skipped': return '跳过';
+      default: return status || '未知';
+    }
+  }
+
   getTaskExternalIds(task: Task): Array<{ key: string; value: string }> {
     return Object.entries(task.externalTaskIds || {})
       .filter(([, value]) => !!value)
@@ -5411,6 +5470,22 @@ export class App {
     };
   }
 
+  private mergeTaskSources(legacyTasks: Task[] = [], generationTasks = this.generationTasks.tasks): Task[] {
+    const byId = new Map<string, Task>();
+
+    for (const task of generationTasks) {
+      byId.set(task.id, this.generationTaskToLegacyTask(task));
+    }
+
+    for (const task of legacyTasks) {
+      if (!task?.id || byId.has(task.id)) continue;
+      byId.set(task.id, task);
+    }
+
+    return Array.from(byId.values())
+      .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
+  }
+
 
 
   analyzeMonitorWork(author: MonitorAuthor, work: MonitorWork): void {
@@ -7117,7 +7192,11 @@ export class App {
       : [];
     let uploadedEntry: any = null;
 
-    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe(
+    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video', {
+      sourceModule: 'app-video-upload',
+      bizType: 'uploaded-video',
+      bizId: file.name,
+    }).pipe(
       switchMap((event) => {
         if (event.state === 'progress') {
           this.uploadProgress = event.progress;
@@ -7583,7 +7662,11 @@ export class App {
     const title = `数字人参考视频 - ${new Date().toLocaleString('zh-CN')}`;
     let uploadedEntry: any = null;
 
-    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe(
+    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video', {
+      sourceModule: 'digital-human',
+      bizType: 'reference-video',
+      bizId: file.name,
+    }).pipe(
       switchMap((event) => {
         if (event.state === 'progress') {
           this.dhRefUploadProgress = event.progress;
@@ -12537,7 +12620,11 @@ ${taskOne}
 
       const filename = `voice-clone-${Date.now()}-${this.vsCloneAudioFile.name}`;
 
-      this.qiniuUpload.uploadFile(this.vsCloneAudioFile, filename, this.vsCloneAudioFile.type, 'audio').subscribe({
+      this.qiniuUpload.uploadFile(this.vsCloneAudioFile, filename, this.vsCloneAudioFile.type, 'audio', {
+        sourceModule: 'voice-clone',
+        bizType: 'clone-audio',
+        bizId: filename,
+      }).subscribe({
 
         next: (url) => {
 
@@ -14315,7 +14402,11 @@ ${this.dhPrompt || '(空)'}
     const title = `视频生成源 - ${new Date().toLocaleString('zh-CN')}`;
     let uploadedEntry: any = null;
 
-    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe(
+    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video', {
+      sourceModule: 'video-generation',
+      bizType: 'source-video',
+      bizId: file.name,
+    }).pipe(
       switchMap((event) => {
         if (event.state === 'progress') {
           this.vgUploadProgress = event.progress;

+ 2 - 2
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -1,6 +1,6 @@
 import { Component, EventEmitter, Input, Output } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import { IP_OPERATOR_ENABLED } from '../../services/ip-operator-flags';
+import { isIpOperatorTabEnabled } from '../../services/ip-operator-flags';
 
 /**
  * 全局侧栏 — 3 大分区纯字排版
@@ -97,7 +97,7 @@ export class AppSidebarComponent {
       .map((zone) => ({
         ...zone,
         items: zone.items.filter((item) => {
-          if (!IP_OPERATOR_ENABLED && item.tab === 'ip-operator') return false;
+          if (!isIpOperatorTabEnabled(item.tab)) return false;
           return true;
         }),
       }))

+ 949 - 1
src/app/models/ip-operator.model.ts

@@ -8,6 +8,43 @@ export type IpStorageMode = 'local' | 'temporary';
 export type IpGenerationStatus = 'pending' | 'running' | 'completed' | 'failed';
 export type IpTopicQualityLevel = 'make_now' | 'polish_first' | 'needs_material' | 'hold';
 export type IpScriptQualityLevel = 'ready_to_record' | 'polish_first' | 'needs_material' | 'outline_only';
+export type IpAssistantActionRisk = 'low' | 'medium' | 'blocked';
+export type IpAssistantActionType =
+  | 'generate_topics'
+  | 'generate_script'
+  | 'create_task'
+  | 'add_calendar_draft'
+  | 'summarize_pain'
+  | 'generate_direction'
+  | 'open_workbench'
+  | 'accept_positioning_version'
+  | 'overwrite_script'
+  | 'batch_adjust_calendar'
+  | 'approve_publish_package'
+  | 'mark_weekly_focus'
+  | 'auto_publish'
+  | 'auto_comment'
+  | 'auto_dm'
+  | 'cookie_pool'
+  | 'proxy_pool';
+
+export interface IpAssistantAction {
+  id: string;
+  type: IpAssistantActionType;
+  label: string;
+  payload: Record<string, unknown>;
+  risk: IpAssistantActionRisk;
+  reason: string;
+  createdAt: string;
+}
+
+export interface IpAssistantPendingConfirmation {
+  id: string;
+  action: IpAssistantAction;
+  status: 'pending' | 'approved' | 'rejected';
+  createdAt: string;
+  updatedAt: string;
+}
 
 export type IpGenerationSection =
   | 'intake_summary'
@@ -106,6 +143,21 @@ export interface IpDiagnosis {
   risks: string[];
   alternativeDirections: string[];
   missingInputs: MissingInput[];
+  accountScores?: {
+    positioningClarity: number;
+    contentStructure: number;
+    interactionConversion: number;
+    recognizability: number;
+    sustainability: number;
+  };
+}
+
+export interface IpTopicScoreDimensions {
+  heatSignal: number;
+  accountFit: number;
+  interactionPotential: number;
+  executability: number;
+  riskScore: number;
 }
 
 export interface BenchmarkAnalysis {
@@ -136,9 +188,15 @@ export interface ContentOpportunity {
 export interface IpTopic {
   id: string;
   title: string;
+  directionId?: string;
   column: string;
   trafficLayer: IpTrafficLayer;
   contentGoal: string;
+  targetAudience?: string;
+  userProblem?: string;
+  evidenceRefs?: string[];
+  viralPatternRefs?: string[];
+  expectedRole?: IpContentDirectionRole;
   priority: IpPriority;
   fitReason: string;
   source: string;
@@ -150,6 +208,7 @@ export interface IpTopic {
   qualityLevel?: IpTopicQualityLevel;
   polishSuggestions?: string[];
   productionReadinessScore?: number;
+  scoreDimensions?: IpTopicScoreDimensions;
 }
 
 export interface IpScript {
@@ -172,6 +231,9 @@ export interface IpScript {
   qualityLevel?: IpScriptQualityLevel;
   scriptSummary?: string;
   productionNotes?: string[];
+  shotList?: IpScriptShot[];
+  recommendedDurationSeconds?: number;
+  durationReason?: string;
 }
 
 export interface ContentTestDay {
@@ -306,6 +368,866 @@ export interface IpBenchmarkEvidence {
   createdAt: string;
 }
 
+export type IpEvidencePlatform = 'douyin' | 'xiaohongshu' | 'wechat' | 'bilibili' | 'kuaishou' | 'tiktok' | 'manual' | 'other';
+export type IpEvidenceKind = 'owned_post' | 'owned_profile' | 'competitor_post' | 'competitor_profile' | 'account_snapshot' | 'comment' | 'trend' | 'feed_signal' | 'content_pattern' | 'user_interview' | 'market_note' | 'review_note' | 'manual_note';
+export type IpEvidenceConfidence = 'high' | 'medium' | 'low';
+export type IpEvidenceBucket = 'accounts' | 'topics' | 'patterns' | 'actions' | 'reviews' | 'raw';
+
+export interface IpEvidenceItem {
+  id: string;
+  platform: IpEvidencePlatform;
+  kind: IpEvidenceKind;
+  bucket: IpEvidenceBucket;
+  accountId?: string;
+  snapshotId?: string;
+  workId?: string;
+  sourceRole?: IpMonitoredAccountRole;
+  title: string;
+  url?: string;
+  authorName?: string;
+  rawText: string;
+  summary: string;
+  tags: string[];
+  relatedBenchmarkId?: string;
+  relatedTopicIds: string[];
+  relatedScriptIds: string[];
+  sourceId?: string;
+  evidenceRefs: string[];
+  selectedForGeneration: boolean;
+  confidence: IpEvidenceConfidence;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type IpPainCategory = 'question' | 'objection' | 'risk' | 'price_value' | 'how_to' | 'identity' | 'purchase_intent' | 'resonance';
+export type IpPainSeverity = 'high' | 'medium' | 'low';
+
+export interface IpCommentPainInsight {
+  id: string;
+  title: string;
+  category: IpPainCategory;
+  severity: IpPainSeverity;
+  summary: string;
+  commentCount: number;
+  keywordFrequency: Array<{ keyword: string; count: number }>;
+  representativeComments: string[];
+  evidenceItemIds: string[];
+  suggestedTopics: string[];
+  scriptAngles: string[];
+  riskBoundaries: string[];
+  labelSystemId?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpCommentLabelSystem {
+  id: string;
+  name: string;
+  evidenceItemIds: string[];
+  labels: Array<{ id: string; level1: string; level2: string; level3: string; description: string }>;
+  personaSummary?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpEvidenceImportResult {
+  imported: number;
+  skipped: number;
+  total: number;
+  evidenceIds: string[];
+}
+
+export type IpCalendarStatus = 'idea' | 'scheduled' | 'drafting' | 'ready_for_review' | 'approved' | 'published' | 'reviewed';
+export type IpMaterialStatus = 'missing' | 'partial' | 'ready';
+
+export interface IpContentCalendarItem {
+  id: string;
+  date: string;
+  platform: IpEvidencePlatform;
+  matrixAccountId?: string;
+  accountName: string;
+  topicId: string;
+  scriptId?: string;
+  title: string;
+  contentType: string;
+  goal: string;
+  status: IpCalendarStatus;
+  materialStatus: IpMaterialStatus;
+  publishPackageId?: string;
+  publicationRecordId?: string;
+  evidenceItemIds: string[];
+  notes: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpMatrixAccount {
+  id: string;
+  platform: IpEvidencePlatform;
+  accountName: string;
+  role: string;
+  audience: string;
+  contentPillars: string[];
+  cadence: string;
+  boundaries: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type IpMonitoredAccountRole = 'owned' | 'benchmark';
+export type IpMonitoredAccountStatus = 'active' | 'removed' | 'archived' | 'deleting' | 'deleted';
+export type IpAccountRefreshStatus = 'idle' | 'refreshing' | 'completed' | 'partial' | 'failed';
+export type IpAccountDataMode = 'data_diagnosis' | 'startup_hypothesis';
+export type IpPositioningProposalStatus = 'proposed' | 'accepted' | 'revised' | 'rejected';
+export type IpOperationTaskColumn = 'today' | 'this_week' | 'later' | 'done';
+export type IpOperationTaskType = 'positioning' | 'evidence' | 'pain' | 'direction' | 'topic' | 'script' | 'publish_pack' | 'review';
+export type IpContentDirectionRole = 'growth' | 'trust' | 'interaction';
+export type IpPublishBindingStatus = 'unbound' | 'auto_matched' | 'manually_bound' | 'rejected';
+export type IpAccountStrategySourceType = 'owned_work' | 'owned_comment' | 'benchmark_work' | 'benchmark_comment' | 'manual_evidence' | 'positioning_version';
+export type IpAccountStrategySourceMode = 'llm' | 'local_fallback';
+export type IpAccountStrategyEvidenceStage = 'raw_context' | 'work_report' | 'strategy_judgment';
+export type IpAccountStrategyImpact = '涨粉' | '播放' | '互动' | '信任';
+export type IpAccountLifecycleStage =
+  | 'unbound'
+  | 'bound_needs_refresh'
+  | 'collected_needs_analysis'
+  | 'analyzed_needs_confirmation'
+  | 'positioning_confirmed'
+  | 'content_plan_ready'
+  | 'publish_package_needs_binding'
+  | 'published_needs_review'
+  | 'review_needs_calibration'
+  | 'calibrated_ready_next_round';
+export type IpAccountLifecycleAction =
+  | 'bind'
+  | 'refresh'
+  | 'analyze'
+  | 'confirm_positioning'
+  | 'choose_topic'
+  | 'bind_publish_work'
+  | 'review'
+  | 'calibrate'
+  | 'next_round';
+export type IpAccountCalibrationAccuracy = 'accurate' | 'partial' | 'wrong';
+export type IpAccountCalibrationSource =
+  | 'positioning_confirmation'
+  | 'manual_calibration'
+  | 'evidence_rejection'
+  | 'direction_feedback'
+  | 'retrospective';
+export type IpRetrospectiveOutcome = 'above_expectation' | 'met_expectation' | 'below_expectation' | 'inconclusive';
+export type IpPublishRetrospectiveNextAction = 'continue' | 'adjust' | 'pause' | 'change_direction';
+export type IpPublishRetrospectiveSourceMode = 'llm' | 'local_fallback';
+export type IpRetrospectiveCauseKey =
+  | 'topic_fit'
+  | 'script_execution'
+  | 'hook_strength'
+  | 'cover_title'
+  | 'publish_timing'
+  | 'positioning_fit'
+  | 'comment_signal'
+  | 'data_insufficient';
+
+export interface IpAccountLifecycleState {
+  stage: IpAccountLifecycleStage;
+  title: string;
+  description: string;
+  nextActionLabel: string;
+  nextAction: IpAccountLifecycleAction;
+  blockers: string[];
+  dataSignals: string[];
+}
+
+export interface IpAccountMonitorScheduleConfig {
+  accountId: string;
+  enabled: boolean;
+  cadence: 'daily' | 'weekly';
+  nextRunAt?: string;
+  lastRunAt?: string;
+  userConfirmed: boolean;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpAccountStrategyEvidenceRef {
+  id: string;
+  sourceType: IpAccountStrategySourceType;
+  sourceId: string;
+  label: string;
+  quote: string;
+  reason: string;
+  confidence?: IpEvidenceConfidence;
+  gaps?: string[];
+  analyzed?: boolean;
+  stage?: IpAccountStrategyEvidenceStage;
+  parentWorkReportId?: string;
+}
+
+export interface IpAccountWorkEvidenceBundle {
+  workId: string;
+  workEvidenceRefId: string;
+  title: string;
+  description: string;
+  transcriptText: string;
+  transcriptSource: IpAccountWorkTranscriptSource;
+  transcriptConfidence: IpEvidenceConfidence;
+  transcriptSummary: string;
+  metricsSummary: string;
+  structureSummary: string;
+  commentEvidenceRefs: Array<{
+    id: string;
+    sourceId: string;
+    text: string;
+    likeCount?: number;
+  }>;
+}
+
+export type IpAccountWorkReportKind = 'structure' | 'operation';
+
+export interface IpAccountWorkReportBlock {
+  id: string;
+  kind: IpAccountWorkReportKind;
+  headline: string;
+  summary: string;
+  evidenceSignals: string[];
+  strategyJudgment: string;
+  supportingEvidenceIds: string[];
+  confidence?: IpEvidenceConfidence;
+  gaps?: string[];
+}
+
+export interface IpAccountWorkStrategyReport {
+  id: string;
+  workId: string;
+  sourceEvidenceId: string;
+  title: string;
+  sourceMode: IpAccountStrategySourceMode;
+  reports: IpAccountWorkReportBlock[];
+  supportingCommentEvidenceIds: string[];
+  supportingCommentSamples?: Array<{
+    id: string;
+    text: string;
+    likeCount?: number;
+    replyCount?: number;
+    authorName?: string;
+  }>;
+  createdAt: string;
+}
+
+export type IpWorkAnalysisStatus =
+  | 'idle'
+  | 'queued'
+  | 'transcribing'
+  | 'transcript_failed'
+  | 'analyzing'
+  | 'completed'
+  | 'failed';
+
+export interface IpTranscriptJobSnapshot {
+  id: string;
+  awemeId: string;
+  status: string;
+  stageLabel?: string;
+  progress?: number;
+  text?: string;
+  warnings?: string[];
+  errorMessage?: string;
+  updatedAt?: string;
+}
+
+export interface IpAccountWorkAnalysisRun {
+  id: string;
+  accountId: string;
+  snapshotId: string;
+  workId: string;
+  awemeId: string;
+  status: IpWorkAnalysisStatus;
+  transcriptJob?: IpTranscriptJobSnapshot;
+  transcriptSource?: IpAccountWorkTranscriptSource;
+  report?: IpAccountWorkStrategyReport;
+  failureReason?: string;
+  diagnostics: IpAccountStrategyDiagnosticEvent[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpEvidenceQualitySignal {
+  sourceId: string;
+  sourceLabel: string;
+  confidence: IpEvidenceConfidence;
+  analyzed: boolean;
+  gaps: string[];
+  reason: string;
+}
+
+export interface IpAccountCalibrationRecord {
+  id: string;
+  accountId: string;
+  reportId?: string;
+  source: IpAccountCalibrationSource;
+  accuracy: IpAccountCalibrationAccuracy;
+  targetAudienceNotes: string;
+  positioningNotes: string;
+  personaNotes?: string;
+  forbiddenTopics: string[];
+  forbiddenExpressions: string[];
+  confirmedDirectionIds: string[];
+  rejectedDirectionIds: string[];
+  rejectedEvidenceIds: string[];
+  operatorExperienceNotes: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpRetrospectiveCauseSignal {
+  key: IpRetrospectiveCauseKey;
+  label: string;
+  score: number;
+  judgment: string;
+  evidence: string[];
+}
+
+export interface IpRetrospectiveDirectionAdjustment {
+  directionId?: string;
+  directionTitle: string;
+  action: 'increase' | 'keep' | 'decrease' | 'pause' | 'test_more';
+  reason: string;
+}
+
+export interface IpRetrospectiveTopicSignal {
+  title: string;
+  source: 'continue_current' | 'answer_comment' | 'fix_script_gap' | 'test_new_angle';
+  targetAudience: string;
+  userProblem: string;
+  reason: string;
+}
+
+export interface IpRetrospectiveScriptConstraint {
+  type: 'hook' | 'structure' | 'cta' | 'visual' | 'tone' | 'risk';
+  instruction: string;
+  avoid?: string;
+}
+
+export interface IpPublishRetrospectiveInsight {
+  sourceMode: IpPublishRetrospectiveSourceMode;
+  confidence: IpEvidenceConfidence;
+  conclusion: string;
+  causeSignals: IpRetrospectiveCauseSignal[];
+  directionAdjustments: IpRetrospectiveDirectionAdjustment[];
+  nextTopicSignals: IpRetrospectiveTopicSignal[];
+  scriptConstraints: IpRetrospectiveScriptConstraint[];
+  positioningImpact: string;
+  nextRoundBrief: string;
+  failureReason?: string;
+}
+
+export interface IpPublishRetrospectiveRecord {
+  id: string;
+  accountId: string;
+  publishPackageId: string;
+  bindingId?: string;
+  workId?: string;
+  awemeId?: string;
+  directionId?: string;
+  topicId?: string;
+  scriptId?: string;
+  snapshotId?: string;
+  roundIndex?: number;
+  reportTitle?: string;
+  generatedFollowUpTopicIds?: string[];
+  sourceMode?: IpPublishRetrospectiveSourceMode;
+  outcome: IpRetrospectiveOutcome;
+  expectedGoal: string[];
+  actualSignals: string[];
+  diagnosis: string;
+  insight?: IpPublishRetrospectiveInsight;
+  nextAction: IpPublishRetrospectiveNextAction;
+  calibrationRecordId?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpAccountWorkPageInfo {
+  fetchedCount: number;
+  hasMore: boolean;
+  cursor?: string;
+  lastLoadedAt: string;
+  source: 'initial' | 'load_more' | 'single_work_bind';
+  pageStateReliable?: boolean;
+}
+
+export interface IpAccountStrategyTopicCandidate {
+  id: string;
+  directionId: string;
+  title: string;
+  targetAudience: string;
+  userProblem: string;
+  expectedRole: IpContentDirectionRole;
+  expectedImpact: IpAccountStrategyImpact;
+  evidenceRefs: string[];
+  viralPatternRefs: string[];
+  avoidCopyingRisk: string;
+  fitReason: string;
+}
+
+export interface IpAccountStrategyDirectionResult {
+  id: string;
+  role: IpContentDirectionRole;
+  title: string;
+  conclusion: string;
+  targetAudience: string;
+  purpose: string;
+  sourceEvidenceIds: string[];
+  viralPatternRefs: string[];
+  commentPainRefs: string[];
+  cadenceSuggestion: string;
+  observeMetrics: string[];
+  fitReason: string;
+  risks: string[];
+  topics: IpAccountStrategyTopicCandidate[];
+}
+
+export type IpAccountStrategyDiagnosticStage =
+  | 'transcript_context'
+  | 'transcript_request'
+  | 'transcript_poll'
+  | 'transcript_result'
+  | 'context_built'
+  | 'llm_request'
+  | 'llm_response'
+  | 'llm_parse'
+  | 'llm_fallback'
+  | 'result_built';
+
+export type IpAccountStrategyDiagnosticStatus = 'started' | 'success' | 'failed' | 'fallback';
+
+export interface IpAccountStrategyDiagnosticEvent {
+  id: string;
+  stage: IpAccountStrategyDiagnosticStage;
+  status: IpAccountStrategyDiagnosticStatus;
+  message: string;
+  detail?: string;
+  createdAt: string;
+}
+
+export interface IpAccountStrategyReport {
+  id: string;
+  accountId: string;
+  snapshotId: string;
+  positioningProposalId: string;
+  sourceMode: IpAccountStrategySourceMode;
+  sourceLabel: string;
+  diagnosisSummary: {
+    accountSnapshot: string;
+    currentIpGuess: string;
+    actualAudience: string;
+    positioningMismatch: string;
+    strongestFollowReason: string;
+    biggestGrowthBlocker: string;
+    highInteractionPattern: string;
+    commentPainSummary: string;
+    sevenDayPriority: string;
+  };
+  evidenceRefs: IpAccountStrategyEvidenceRef[];
+  workReports?: IpAccountWorkStrategyReport[];
+  directions: IpAccountStrategyDirectionResult[];
+  taskSuggestions: Array<{
+    title: string;
+    reason: string;
+    type: IpOperationTaskType;
+    column: IpOperationTaskColumn;
+    growthImpactScore: number;
+    relatedEvidenceIds: string[];
+  }>;
+  appliedCalibrationIds?: string[];
+  appliedCalibrationSummary?: string[];
+  confidenceLevel?: IpEvidenceConfidence;
+  evidenceGaps?: string[];
+  failureReason?: string;
+  diagnostics?: IpAccountStrategyDiagnosticEvent[];
+  dataScopeSummary?: {
+    workCount: number;
+    commentCount: number;
+    hasPositioningVersion: boolean;
+    calibrationCount: number;
+    publishBindingCount: number;
+  };
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpMonitoredAccount {
+  id: string;
+  userId: string;
+  platform: IpEvidencePlatform;
+  role: IpMonitoredAccountRole;
+  profileId?: string;
+  planId?: string;
+  displayName: string;
+  homepageUrl?: string;
+  accountId?: string;
+  secUserId?: string;
+  uniqueId?: string;
+  intendedTrack?: string;
+  intendedPersona?: string;
+  intendedAudience?: string;
+  benchmarkReason?: string;
+  enabled: boolean;
+  accountStatus?: IpMonitoredAccountStatus;
+  statusChangedAt?: string;
+  lastRefreshStatus: IpAccountRefreshStatus;
+  lastRefreshedAt?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpAccountDeletionPreviewItem {
+  entityType: string;
+  label: string;
+  count: number;
+  highValue: boolean;
+  purgeInMvp: boolean;
+}
+
+export interface IpAccountDeletionPreview {
+  accountId: string;
+  accountName: string;
+  generatedAt: string;
+  totalRows: number;
+  highValueRows: number;
+  items: IpAccountDeletionPreviewItem[];
+  confirmationText: string;
+  warnings: string[];
+}
+
+export type IpAccountDeletionJobStatus = 'queued' | 'running' | 'completed' | 'failed';
+
+export interface IpAccountDeletionJob {
+  id: string;
+  accountId: string;
+  accountName: string;
+  status: IpAccountDeletionJobStatus;
+  preview: IpAccountDeletionPreview;
+  deletedCounts: Record<string, number>;
+  failedCounts: Record<string, number>;
+  errorMessage?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpAccountCleanupRecommendation {
+  accountId: string;
+  accountName: string;
+  snapshotCount: number;
+  workCount: number;
+  commentSampleCount: number;
+  reasons: string[];
+  recommendedAction: 'archive' | 'preview_delete' | 'trim_samples';
+}
+
+export interface IpAccountProfileSnapshot {
+  nickname: string;
+  signature: string;
+  avatarUrl?: string;
+  avatarCandidates?: string[];
+  followerCount?: number;
+  followingCount?: number;
+  totalFavorited?: number;
+  awemeCount?: number;
+}
+
+export interface IpAccountWorkMetricSnapshot {
+  playCount?: number;
+  likeCount?: number;
+  commentCount?: number;
+  collectCount?: number;
+  shareCount?: number;
+  followerGain?: number;
+}
+
+export type IpAccountWorkTranscriptSource = 'detail' | 'caption' | 'subtitle' | 'asr' | 'manual' | 'viral_analysis' | 'unknown';
+
+export interface IpAccountWorkTranscriptSnapshot {
+  text: string;
+  source: IpAccountWorkTranscriptSource;
+  confidence: IpEvidenceConfidence;
+  capturedAt: string;
+}
+
+export interface IpAccountCommentSample {
+  id: string;
+  workId: string;
+  text: string;
+  likeCount?: number;
+  replyCount?: number;
+  authorName?: string;
+  capturedAt: string;
+}
+
+export type IpAccountWorkImageSource = 'platform_cover' | 'origin_preview' | 'dynamic_preview' | 'image_group' | 'fallback';
+
+export interface IpAccountWorkSnapshot {
+  id: string;
+  accountId: string;
+  awemeId: string;
+  title: string;
+  desc: string;
+  coverUrl?: string;
+  coverCandidates?: string[];
+  coverSource?: IpAccountWorkImageSource;
+  coverCandidateSources?: Record<string, IpAccountWorkImageSource>;
+  publishTime?: string;
+  url?: string;
+  transcript?: IpAccountWorkTranscriptSnapshot;
+  metrics: IpAccountWorkMetricSnapshot;
+  interactionScore: number;
+  isDeepSampled: boolean;
+  structure?: {
+    hook: string;
+    topic: string;
+    style: string;
+    cta: string;
+  };
+  comments: IpAccountCommentSample[];
+  capturedAt: string;
+}
+
+export interface IpAccountSnapshot {
+  id: string;
+  accountId: string;
+  profile: IpAccountProfileSnapshot;
+  works: IpAccountWorkSnapshot[];
+  workPageInfo?: IpAccountWorkPageInfo;
+  dataMode: IpAccountDataMode;
+  evidenceItemIds: string[];
+  capturedAt: string;
+  warnings: string[];
+}
+
+export interface IpAccountDiagnosisRecord {
+  id: string;
+  accountId: string;
+  snapshotId: string;
+  mode: IpAccountDataMode;
+  reportMarkdown: string;
+  scores: IpAccountDiagnosisScores;
+  evidenceItemIds: string[];
+  topWorkIds: string[];
+  commentSampleCount: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpAccountDiagnosisScores {
+  positioningClarity: number;
+  contentStructure: number;
+  interactionConversion: number;
+  recognizability: number;
+  sustainability: number;
+}
+
+export interface IpPositioningVersion {
+  id: string;
+  accountId: string;
+  version: number;
+  status: 'active' | 'archived';
+  targetAudience: string;
+  persona: string;
+  followReason: string;
+  contentPillars: string[];
+  expressionStyle: string[];
+  boundaries: string[];
+  suitableViralPatterns: string[];
+  evidenceItemIds: string[];
+  assumptionsToValidate: string[];
+  observeMetrics: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpPositioningProposal {
+  id: string;
+  accountId: string;
+  snapshotId?: string;
+  baseVersionId?: string;
+  status: IpPositioningProposalStatus;
+  reason: string;
+  proposedVersion: Omit<IpPositioningVersion, 'id' | 'version' | 'status' | 'createdAt' | 'updatedAt'>;
+  evidenceItemIds: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpContentDirection {
+  id: string;
+  accountId: string;
+  positioningVersionId: string;
+  role: IpContentDirectionRole;
+  title: string;
+  targetAudience: string;
+  purpose: string;
+  sourceEvidenceIds: string[];
+  viralPatternRefs: string[];
+  commentPainRefs: string[];
+  topicIds: string[];
+  cadenceSuggestion: string;
+  observeMetrics: string[];
+  fitReason?: string;
+  risks?: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpOperationTask {
+  id: string;
+  accountId: string;
+  column: IpOperationTaskColumn;
+  type: IpOperationTaskType;
+  title: string;
+  reason: string;
+  growthImpactScore: number;
+  relatedEvidenceIds: string[];
+  relatedDirectionId?: string;
+  relatedTopicId?: string;
+  relatedScriptId?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpScriptShot {
+  id: string;
+  startSecond: number;
+  endSecond: number;
+  segmentTitle: string;
+  visualDesign: string;
+  narration: string;
+  subtitle: string;
+  actionNotes: string;
+  audioNotes: string;
+}
+
+export interface IpPublishWorkBinding {
+  id: string;
+  publishPackageId: string;
+  accountId: string;
+  awemeId?: string;
+  workUrl?: string;
+  status: IpPublishBindingStatus;
+  matchScore?: number;
+  matchReason?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type IpPublishPackageStatus = 'draft' | 'ready_for_review' | 'approved' | 'published' | 'reviewed';
+
+export interface IpPublishChecklistItem {
+  id: string;
+  label: string;
+  checked: boolean;
+  required: boolean;
+}
+
+export interface IpPublishPlatformVariants {
+  xiaohongshu: {
+    title: string;
+    coverText: string;
+    imageNoteOutline: string[];
+    hashtags: string[];
+  };
+  douyin: {
+    firstThreeSecondsHook: string;
+    shotList: string[];
+    spokenScript: string;
+    subtitleEmphasis: string[];
+  };
+  wechat: {
+    calmerTitle: string;
+    intro: string;
+    body: string;
+    endingCta: string;
+  };
+}
+
+export interface IpPublishPackage {
+  id: string;
+  calendarItemId: string;
+  topicId: string;
+  scriptId?: string;
+  platform: IpEvidencePlatform;
+  accountName: string;
+  titleOptions: string[];
+  coverSuggestions: string[];
+  caption: string;
+  scriptText: string;
+  videoGoal?: string[];
+  durationAndRhythm?: string;
+  shootingChecklist?: string[];
+  subtitleHighlights?: string[];
+  audioSuggestion?: string;
+  hashtags: string[];
+  commentGuidance?: string;
+  pinnedCommentSuggestion?: string;
+  postPublishMonitoringTargets?: string[];
+  materialFiles: Array<{ id: string; name: string; path?: string; status: IpMaterialStatus }>;
+  scheduleSuggestion?: string;
+  productLink?: string;
+  productTitle?: string;
+  isOriginal: boolean;
+  isDraft: boolean;
+  materialChecklist: IpPublishChecklistItem[];
+  riskChecklist: IpPublishChecklistItem[];
+  platformVariants?: IpPublishPlatformVariants;
+  polishSource?: 'rule_draft' | 'llm' | 'local_fallback';
+  polishSummary?: string;
+  polishRationale?: string[];
+  polishFailureReason?: string;
+  polishedAt?: string;
+  evidenceItemIds: string[];
+  painInsightIds: string[];
+  status: IpPublishPackageStatus;
+  publicationRecordId?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpRetrospectiveFollowUpTopic {
+  id: string;
+  publicationRecordId: string;
+  title: string;
+  reason: string;
+  sourceSuggestions: string[];
+  tags: string[];
+  status?: 'pending' | 'accepted' | 'dismissed';
+  selected: boolean;
+  syncedTopicId?: string;
+  planTopicId?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpCalendarDashboardSummary {
+  key: string;
+  label: string;
+  count: number;
+  avgScore?: number;
+  totalViews?: number;
+}
+
+export interface IpCalendarDashboard {
+  totalItems: number;
+  linkedPublications: number;
+  totalViews: number;
+  totalEngagement: number;
+  avgScore: number;
+  platformSummary: IpCalendarDashboardSummary[];
+  accountSummary: IpCalendarDashboardSummary[];
+  statusSummary: IpCalendarDashboardSummary[];
+  pillarScores: IpCalendarDashboardSummary[];
+  painCategoryScores: IpCalendarDashboardSummary[];
+  nextWeekRecommendations: string[];
+}
+
 export interface IpEvidencePolishSuggestion {
   id: string;
   planId: string;
@@ -381,7 +1303,17 @@ export interface IpOperatorPlan {
   bridgeRecords?: IpOperatorBridgeRecord[];
   evidenceFetchJobs?: IpEvidenceFetchJob[];
   benchmarkEvidence?: IpBenchmarkEvidence[];
+  evidenceItems?: IpEvidenceItem[];
+  commentPainInsights?: IpCommentPainInsight[];
+  commentLabelSystems?: IpCommentLabelSystem[];
+  contentCalendar?: IpContentCalendarItem[];
+  publishPackages?: IpPublishPackage[];
+  retrospectiveFollowUpTopics?: IpRetrospectiveFollowUpTopic[];
+  matrixAccounts?: IpMatrixAccount[];
   evidenceSuggestions?: IpEvidencePolishSuggestion[];
+  accountStrategyReports?: IpAccountStrategyReport[];
+  calibrationRecords?: IpAccountCalibrationRecord[];
+  publishRetrospectives?: IpPublishRetrospectiveRecord[];
   qualityAudit?: IpPlanQualityAudit;
   errorMessage?: string;
   createdAt: string;
@@ -408,7 +1340,23 @@ export interface IpGenerationEvent {
 export interface IpOperatorAuditEvent {
   id: string;
   action: string;
-  entityType: 'profile' | 'benchmark' | 'plan' | 'topic' | 'script' | 'storage' | 'generation';
+  entityType:
+    | 'profile'
+    | 'benchmark'
+    | 'plan'
+    | 'topic'
+    | 'script'
+    | 'storage'
+    | 'generation'
+    | 'account'
+    | 'account_snapshot'
+    | 'account_work_analysis_run'
+    | 'account_diagnosis'
+    | 'positioning_proposal'
+    | 'positioning_version'
+    | 'content_direction'
+    | 'operation_task'
+    | 'publish_binding';
   entityId?: string;
   userId: string;
   createdAt: string;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 4355 - 642
src/app/pages/ip-operator/ip-operator.component.css


+ 1287 - 633
src/app/pages/ip-operator/ip-operator.component.html

@@ -3,11 +3,12 @@
     <div class="dh-header-icon"><span class="icon">hub</span></div>
     <div class="dh-header-text">
       <h1>IP操盘工作台</h1>
-      <p>从建档、轻问卷、对标账号,到定位诊断、选题脚本和 7 天测试计划,形成可继续打磨的起步方案。</p>
+      <p>围绕真实账号证据推进诊断、定位、任务、选题和发布复盘,低价值历史工具不再占用主流程。</p>
     </div>
     <div class="ip-header-actions">
-      <button class="btn" type="button" (click)="startNewProfile()">新建档案</button>
-      <button class="btn" type="button" (click)="reloadState()">刷新记录</button>
+      <button class="btn" type="button" [disabled]="workspaceRefreshing()" (click)="refreshWorkspaceState()">
+        {{ workspaceRefreshing() ? '同步中...' : '刷新记录' }}
+      </button>
     </div>
   </header>
 
@@ -18,726 +19,1379 @@
     <button class="btn btn-sm" type="button" (click)="clearTemporaryData()">清空临时资料</button>
   </div>
 
-  <div class="ip-shell">
-    <aside class="ip-side">
-      <h3>IP 档案</h3>
-      <button class="ip-profile-item"
-              type="button"
-              *ngFor="let profile of profiles(); trackBy: trackById"
-              (click)="loadProfile(profile)">
-        <strong>{{ profile.name }}</strong>
-        <span>{{ profile.identity }} / {{ profile.industry }}</span>
-      </button>
+  <section class="ip-card account-workbench">
+    <div class="operator-cockpit">
+      <div class="operator-cockpit__main">
+        <p class="account-workbench-kicker">账号驱动</p>
+        <h2>账号运营驾驶舱</h2>
+        <p>先看当前账号状态、定位版本和今天动作,再进入诊断、定位、任务和选题工作区。</p>
+      </div>
 
-      <div class="ip-audit">
-        <h3>修改记录</h3>
-        <p *ngIf="!auditTrail().length">暂无记录</p>
-        <article *ngFor="let event of auditTrail() | slice:0:8; trackBy: trackById">
-          <strong>{{ event.summary }}</strong>
-          <span>{{ event.createdAt | date:'MM-dd HH:mm:ss' }}</span>
-        </article>
+      <div class="operator-cockpit__status" *ngIf="activeAccount() as account">
+        <section>
+          <span>当前账号</span>
+          <strong>{{ account.displayName }}</strong>
+          <small>{{ account.homepageUrl || account.uniqueId || account.secUserId || account.accountId }}</small>
+        </section>
+        <section>
+          <span>刷新状态</span>
+          <strong>{{ accountRefreshStatusLabel(account.lastRefreshStatus) }}</strong>
+          <small>{{ account.lastRefreshedAt ? (account.lastRefreshedAt | date:'MM-dd HH:mm') : '尚未刷新' }}</small>
+        </section>
+        <section>
+          <span>诊断模式</span>
+          <strong>{{ activeSnapshot()?.dataMode === 'data_diagnosis' ? '数据诊断' : '起号假设' }}</strong>
+          <small>{{ activeSnapshot() ? '基于账号快照' : '等待刷新账号数据' }}</small>
+        </section>
+        <section>
+          <span>定位版本</span>
+          <strong>{{ activePositioningVersion()?.persona || '待确认' }}</strong>
+          <small>{{ activePositioningVersion() ? '已生效' : '请先确认定位提案' }}</small>
+        </section>
       </div>
-    </aside>
-
-    <main class="ip-main">
-      <ol class="ip-steps">
-        <li [class.is-active]="step() === 1">1 建档</li>
-        <li [class.is-active]="step() === 2">2 轻问卷</li>
-        <li [class.is-active]="step() === 3">3 对标</li>
-        <li [class.is-active]="step() === 4">4 生成</li>
-        <li [class.is-active]="step() === 5">5 方案</li>
-      </ol>
-
-      <section class="ip-card" *ngIf="step() === 1">
-        <h2>创建 IP 档案</h2>
-        <div class="ip-form-grid">
-          <label>IP 名称<input [ngModel]="profileDraft().name" (ngModelChange)="updateProfileDraft({name: $event})"></label>
-          <label>当前身份<input [ngModel]="profileDraft().identity" (ngModelChange)="updateProfileDraft({identity: $event})"></label>
-          <label>所在行业<input [ngModel]="profileDraft().industry" (ngModelChange)="updateProfileDraft({industry: $event})"></label>
-          <label>账号阶段
-            <select [ngModel]="profileDraft().stage" (ngModelChange)="updateProfileDraft({stage: $event})">
-              <option value="new">未开始</option>
-              <option value="started">刚注册/少量内容</option>
-              <option value="restart">老号重启</option>
-              <option value="existing">已有稳定账号</option>
-            </select>
-          </label>
-          <label>目标用户<input [ngModel]="profileDraft().targetAudience" (ngModelChange)="updateProfileDraft({targetAudience: $event})"></label>
-          <label>产品/服务<input [ngModel]="profileDraft().productsOrServices" (ngModelChange)="updateProfileDraft({productsOrServices: $event})"></label>
-        </div>
-        <label>用户痛点<textarea rows="3" [ngModel]="profileDraft().audiencePainPointsText" (ngModelChange)="updateProfileDraft({audiencePainPointsText: $event})"></textarea></label>
-        <label>个人经历<textarea rows="3" [ngModel]="profileDraft().personalStoriesText" (ngModelChange)="updateProfileDraft({personalStoriesText: $event})"></textarea></label>
-        <label>专业能力<textarea rows="3" [ngModel]="profileDraft().expertiseText" (ngModelChange)="updateProfileDraft({expertiseText: $event})"></textarea></label>
-        <label>表达风格<textarea rows="2" [ngModel]="profileDraft().expressionStyleText" (ngModelChange)="updateProfileDraft({expressionStyleText: $event})"></textarea></label>
-        <label>不想讲的内容<textarea rows="2" [ngModel]="profileDraft().boundariesText" (ngModelChange)="updateProfileDraft({boundariesText: $event})"></textarea></label>
-        <label>期望结果<textarea rows="2" [ngModel]="profileDraft().goalsText" (ngModelChange)="updateProfileDraft({goalsText: $event})"></textarea></label>
-        <div class="ip-actions">
-          <button class="btn btn-primary" type="button" (click)="step.set(2)">下一步:轻问卷</button>
+
+      <section class="account-lifecycle-card" *ngIf="accountLifecycleState() as state">
+        <div>
+          <span class="ip-kicker">当前阶段</span>
+          <h3>{{ state.title }}</h3>
+          <p>{{ state.description }}</p>
+        </div>
+        <div class="account-lifecycle-card__next">
+          <strong>下一步:{{ state.nextActionLabel }}</strong>
+          <small *ngIf="state.blockers.length">待补齐:{{ state.blockers.join('、') }}</small>
+          <small *ngIf="!state.blockers.length">当前没有关键阻塞,可以进入下一轮分析或执行。</small>
         </div>
+        <ul *ngIf="state.dataSignals.length">
+          <li *ngFor="let signal of state.dataSignals">{{ signal }}</li>
+        </ul>
       </section>
 
-      <section class="ip-card" *ngIf="step() === 2">
-        <h2>轻问卷</h2>
-        <label *ngFor="let question of questionnaire; let i = index; trackBy: trackByIndex">
-          {{ i + 1 }}. {{ question }}
-          <textarea rows="2" [ngModel]="questionnaireAnswers()[i]" (ngModelChange)="updateQuestionAnswer(i, $event)"></textarea>
+      <div class="operator-cockpit__empty" *ngIf="!activeAccount()">
+        <strong>先绑定抖音账号</strong>
+        <span>绑定后才能刷新公开主页、作品和评论样本。</span>
+      </div>
+
+      <div class="account-bind-row">
+        <label>
+          绑定抖音账号
+          <input
+            [ngModel]="accountInput()"
+            (ngModelChange)="accountInput.set($event)"
+            placeholder="粘贴抖音主页链接或账号 ID">
         </label>
-        <div class="ip-actions">
-          <button class="btn" type="button" (click)="step.set(1)">上一步</button>
-          <button class="btn btn-primary" type="button" (click)="step.set(3)">下一步:对标账号</button>
+        <button class="btn btn-primary" type="button" [disabled]="primaryAction().disabled" (click)="runPrimaryAction()">
+          {{ primaryAction().label }}
+        </button>
+        <button class="btn" type="button" [disabled]="accountRefreshing() || !activeAccount()" (click)="refreshActiveAccount()">
+          {{ accountRefreshing() ? '刷新中...' : '刷新账号数据' }}
+        </button>
+        <button class="btn" type="button" [disabled]="workspaceRefreshing()" (click)="refreshWorkspaceState()">
+          {{ workspaceRefreshing() ? '同步中...' : '刷新工作台' }}
+        </button>
+        <button class="btn" type="button" [disabled]="!activeSnapshot() || accountDiagnosisRunning()" (click)="runAccountDiagnosis()">
+          {{ accountDiagnosisRunning() ? '正在分析Top3作品...' : '分析账号定位' }}
+        </button>
+        <button class="btn" type="button" [disabled]="!activeSnapshot() || accountDiagnosisRunning()" (click)="generateAccountReportFromCompletedWorkReports()">
+          用已生成作品报告重算定位
+        </button>
+      </div>
+
+      <section class="account-manager-panel" *ngIf="monitoredAccounts().length || removedAccounts().length || archivedAccounts().length || accountDeletionJobs().length">
+        <header>
+          <div>
+            <span class="ip-kicker">账号管理</span>
+            <h3>运营账号生命周期</h3>
+          </div>
+          <small>移出用于整理工作台,归档用于长期只读保留,彻底删除会先进入影响预览。</small>
+        </header>
+
+        <div class="account-manager-tabs" role="tablist" aria-label="账号管理分组">
+          <button type="button" [class.is-active]="accountManagerView() === 'active'" (click)="accountManagerView.set('active')">
+            当前运营 <strong>{{ monitoredAccounts().length }}</strong>
+          </button>
+          <button type="button" [class.is-active]="accountManagerView() === 'removed'" (click)="accountManagerView.set('removed')">
+            已移出 <strong>{{ removedAccounts().length }}</strong>
+          </button>
+          <button type="button" [class.is-active]="accountManagerView() === 'archived'" (click)="accountManagerView.set('archived')">
+            已归档 <strong>{{ archivedAccounts().length }}</strong>
+          </button>
+          <button type="button" [class.is-active]="accountManagerView() === 'deletions'" (click)="accountManagerView.set('deletions')">
+            删除记录 <strong>{{ accountDeletionJobs().length }}</strong>
+          </button>
+        </div>
+
+        <div class="account-switch-list" *ngIf="accountManagerView() === 'active'">
+          <article
+            class="account-switch-card"
+            *ngFor="let account of monitoredAccounts(); trackBy: trackById"
+            [class.is-active]="activeAccount()?.id === account.id">
+            <button
+              class="account-switch-card__main"
+              type="button"
+              [attr.aria-current]="activeAccount()?.id === account.id ? 'true' : null"
+              (click)="selectMonitoredAccount(account)">
+              <strong>{{ account.displayName }}</strong>
+              <span>{{ accountRefreshStatusLabel(account.lastRefreshStatus) }} / {{ account.lastRefreshedAt ? (account.lastRefreshedAt | date:'MM-dd HH:mm') : '未刷新' }}</span>
+            </button>
+            <div class="account-switch-card__actions">
+              <button class="btn btn-sm" type="button" (click)="archiveMonitoredAccount(account, $event)">归档</button>
+              <button class="btn btn-sm" type="button" (click)="removeMonitoredAccount(account, $event)">移出</button>
+            </div>
+          </article>
+          <p class="account-manager-empty" *ngIf="!monitoredAccounts().length">没有当前运营账号。</p>
+        </div>
+
+        <div class="account-switch-list" *ngIf="accountManagerView() === 'removed'">
+          <article class="account-switch-card is-muted" *ngFor="let account of removedAccounts(); trackBy: trackById">
+            <div class="account-switch-card__main">
+              <strong>{{ account.displayName }}</strong>
+              <span>已移出 / {{ account.statusChangedAt ? (account.statusChangedAt | date:'MM-dd HH:mm') : '保留历史数据' }}</span>
+            </div>
+            <div class="account-switch-card__actions">
+              <button class="btn btn-sm" type="button" (click)="restoreMonitoredAccount(account, $event)">恢复</button>
+              <button class="btn btn-sm" type="button" (click)="archiveMonitoredAccount(account, $event)">归档</button>
+              <button class="btn btn-sm btn-danger" type="button" (click)="openAccountDeletionPreview(account, $event)">彻底删除</button>
+            </div>
+          </article>
+          <p class="account-manager-empty" *ngIf="!removedAccounts().length">没有已移出的账号。</p>
+        </div>
+
+        <div class="account-switch-list" *ngIf="accountManagerView() === 'archived'">
+          <article class="account-switch-card is-muted" *ngFor="let account of archivedAccounts(); trackBy: trackById">
+            <div class="account-switch-card__main">
+              <strong>{{ account.displayName }}</strong>
+              <span>已归档 / 不参与刷新和任务生成</span>
+            </div>
+            <div class="account-switch-card__actions">
+              <button class="btn btn-sm" type="button" (click)="unarchiveMonitoredAccount(account, $event)">取消归档</button>
+              <button class="btn btn-sm btn-danger" type="button" (click)="openAccountDeletionPreview(account, $event)">彻底删除</button>
+            </div>
+          </article>
+          <p class="account-manager-empty" *ngIf="!archivedAccounts().length">没有已归档的账号。</p>
+        </div>
+
+        <div class="account-deletion-jobs" *ngIf="accountManagerView() === 'deletions'">
+          <article *ngFor="let job of accountDeletionJobs(); trackBy: trackById">
+            <div>
+              <strong>{{ job.accountName }}</strong>
+              <span>{{ job.status === 'completed' ? '已完成' : job.status === 'failed' ? '失败' : '处理中' }} / {{ job.updatedAt | date:'MM-dd HH:mm' }}</span>
+            </div>
+            <small>删除 {{ job.deletedCounts | json }}</small>
+          </article>
+          <p class="account-manager-empty" *ngIf="!accountDeletionJobs().length">暂无删除记录。</p>
         </div>
       </section>
 
-      <section class="ip-card" *ngIf="step() === 3">
-        <p class="ip-step-help">只填对标线索即可。爆点、数据、评论痛点和转化观察由 AI 分析,人工观察为可选补充。</p>
-        <h2>对标账号 / 爆款线索</h2>
-        <article class="ip-benchmark" *ngFor="let item of benchmarkDrafts(); let i = index; trackBy: trackByIndex">
-          <div class="ip-benchmark-head">
-            <strong>对标 {{ i + 1 }}</strong>
-            <button class="btn btn-sm" type="button" (click)="removeBenchmark(i)">删除</button>
-          </div>
-          <div class="ip-benchmark-intake">
-            <strong>AI 分析所需线索</strong>
-            <span>至少填账号、主页或爆款链接/ID中的一项。</span>
+      <section class="account-cleanup-panel" *ngIf="accountCleanupRecommendations().length">
+        <header>
+          <div>
+            <span class="ip-kicker">数据清理建议</span>
+            <h3>先处理低价值增长数据</h3>
           </div>
-          <div class="ip-form-grid">
-            <label>账号名称<input [ngModel]="item.name" (ngModelChange)="updateBenchmark(i, {name: $event})" placeholder="例如:老张聊实体生意"></label>
-            <label>账号链接<input [ngModel]="item.url" (ngModelChange)="updateBenchmark(i, {url: $event})" placeholder="主页链接,可选"></label>
+          <small>当前只做建议,不自动删除定位、任务和发布绑定。</small>
+        </header>
+        <article *ngFor="let item of accountCleanupRecommendations(); trackBy: trackByAccountId" class="account-cleanup-item">
+          <div>
+            <strong>{{ item.accountName }}</strong>
+            <span>快照 {{ item.snapshotCount }} / 作品 {{ item.workCount }} / 评论样本 {{ item.commentSampleCount }}</span>
+            <small>{{ item.reasons.join(';') }}</small>
           </div>
-          <label>爆款作品链接 / awemeId<input [ngModel]="item.viralUrl" (ngModelChange)="updateBenchmark(i, {viralUrl: $event})" placeholder="推荐填写,用于后续证据分析"></label>
-          <label>值得借鉴原因(可选)<textarea rows="2" [ngModel]="item.reasonToBenchmark" (ngModelChange)="updateBenchmark(i, {reasonToBenchmark: $event})" placeholder="你的主观判断,可留空"></textarea></label>
-          <details class="ip-benchmark-optional">
-            <summary>人工补充观察(可选,不填也可以生成)</summary>
-            <label>账号简介<textarea rows="2" [ngModel]="item.bio" (ngModelChange)="updateBenchmark(i, {bio: $event})" placeholder="可留空"></textarea></label>
-            <div class="ip-form-grid">
-              <label>已知爆款标题/描述<input [ngModel]="item.viralTitle" (ngModelChange)="updateBenchmark(i, {viralTitle: $event})" placeholder="可留空"></label>
-              <label>已知数据表现<input [ngModel]="item.performance" (ngModelChange)="updateBenchmark(i, {performance: $event})" placeholder="可留空"></label>
-            </div>
-            <label>你观察到的爆点<textarea rows="2" [ngModel]="item.perceivedHook" (ngModelChange)="updateBenchmark(i, {perceivedHook: $event})" placeholder="可留空"></textarea></label>
-            <label>你观察到的转化线索<textarea rows="2" [ngModel]="item.conversionNotes" (ngModelChange)="updateBenchmark(i, {conversionNotes: $event})" placeholder="可留空"></textarea></label>
-          </details>
         </article>
-        <div class="ip-actions">
-          <button class="btn" type="button" (click)="addBenchmark()">添加对标</button>
-          <span class="spacer"></span>
-          <button class="btn" type="button" (click)="step.set(2)">上一步</button>
-          <button class="btn btn-primary" type="button" [disabled]="!hasBenchmarkInput()" (click)="step.set(4)">下一步:确认生成</button>
-        </div>
       </section>
 
-      <section class="ip-card" *ngIf="step() === 4">
-        <h2>确认生成范围</h2>
-        <div class="ip-generate-scope">
-          <span>IP 定位报告</span>
-          <span>对标拆解</span>
-          <span>机会地图</span>
-          <span>12 个核心选题</span>
-          <span>9 条脚本大纲</span>
-          <span>3 条完整口播</span>
-          <span>7 天测试计划</span>
-          <span>30 天方向</span>
-          <span>质量检查</span>
-        </div>
-        <div class="ip-progress" *ngIf="activePlan() as plan">
-          <div *ngFor="let item of plan.generationProgress; trackBy: trackByStage" [class]="'ip-progress-item is-' + item.status">
-            <strong>{{ item.label }}</strong>
-            <span>{{ item.status }}</span>
-            <small *ngIf="item.durationMs">{{ item.durationMs }}ms</small>
+      <section class="account-deletion-modal" *ngIf="accountDeletionPreview() as preview" role="dialog" aria-modal="true" aria-label="删除影响预览">
+        <div class="account-deletion-modal__panel">
+          <header>
+            <div>
+              <span class="ip-kicker">删除影响预览</span>
+              <h3>彻底删除「{{ preview.accountName }}」</h3>
+            </div>
+            <button class="btn btn-sm" type="button" (click)="closeAccountDeletionPreview()">关闭</button>
+          </header>
+
+          <div class="account-deletion-summary">
+            <section>
+              <span>总影响数据</span>
+              <strong>{{ preview.totalRows }}</strong>
+            </section>
+            <section>
+              <span>高价值资产</span>
+              <strong>{{ preview.highValueRows }}</strong>
+            </section>
+          </div>
+
+          <ul class="account-deletion-warnings">
+            <li *ngFor="let warning of preview.warnings">{{ warning }}</li>
+          </ul>
+
+          <div class="account-deletion-table">
+            <article *ngFor="let item of preview.items" [class.is-high-value]="item.highValue">
+              <span>{{ item.label }}</span>
+              <strong>{{ item.count }}</strong>
+              <small>{{ item.purgeInMvp ? '本次删除' : '本次保留' }}</small>
+            </article>
           </div>
+
+          <label class="account-deletion-confirm">
+            请输入「{{ preview.confirmationText }}」确认删除
+            <input [ngModel]="accountDeletionConfirmation()" (ngModelChange)="accountDeletionConfirmation.set($event)" [placeholder]="preview.confirmationText">
+          </label>
+
+          <footer>
+            <button class="btn" type="button" (click)="closeAccountDeletionPreview()">取消</button>
+            <button
+              class="btn btn-danger"
+              type="button"
+              [disabled]="accountDeletionRunning() || accountDeletionConfirmation().trim() !== preview.confirmationText"
+              (click)="confirmAccountDeletion()">
+              {{ accountDeletionRunning() ? '删除中...' : '确认彻底删除' }}
+            </button>
+          </footer>
+        </div>
+      </section>
+
+      <div class="operator-cockpit__next">
+        <section>
+          <span>今天动作</span>
+          <strong>{{ todayTaskCount() }} 项</strong>
+          <small>{{ todayTaskCount() ? '优先处理今天必须做' : '暂无今日任务' }}</small>
+        </section>
+        <section>
+          <span>发布复盘</span>
+          <strong>{{ pendingPublishCount() }} / {{ pendingBindingCount() }}</strong>
+          <small>待发布包 / 待绑定</small>
+        </section>
+        <section>
+          <span>证据样本</span>
+          <strong>{{ activeSnapshot()?.works?.length || 0 }}</strong>
+          <small>默认展示重点作品</small>
+        </section>
+      </div>
+    </div>
+
+    <p class="ip-success" *ngIf="workspaceRefreshMessage()">{{ workspaceRefreshMessage() }}</p>
+    <p class="ip-success" *ngIf="accountRefreshMessage()">{{ accountRefreshMessage() }}</p>
+
+    <section class="assistant-confirm-panel" *ngIf="assistantConfirmations().length || assistantBlockedMessage()">
+      <header>
+        <div>
+          <span class="ip-kicker">AI 运营副驾</span>
+          <h3>动作确认与风控边界</h3>
+        </div>
+      </header>
+      <p *ngIf="assistantBlockedMessage()" class="assistant-confirm-panel__blocked">{{ assistantBlockedMessage() }}</p>
+      <article *ngFor="let item of assistantConfirmations(); trackBy: trackById" class="assistant-confirm-panel__item" [class.is-resolved]="item.status !== 'pending'">
+        <div>
+          <strong>{{ item.action.label }}</strong>
+          <p>{{ item.action.reason }}</p>
+          <small>风险等级:{{ item.action.risk === 'medium' ? '需要确认' : item.action.risk }}</small>
+        </div>
+        <div class="ip-actions" *ngIf="item.status === 'pending'">
+          <button class="btn btn-primary btn-sm" type="button" (click)="approveAssistantConfirmation(item.id)">确认执行</button>
+          <button class="btn btn-sm" type="button" (click)="rejectAssistantConfirmation(item.id)">拒绝</button>
+        </div>
+        <span *ngIf="item.status !== 'pending'">{{ item.status === 'approved' ? '已确认' : '已拒绝' }}</span>
+      </article>
+    </section>
+
+    <div class="account-flow-mini">
+      <span [class.is-active]="!activeAccount()">绑定</span>
+      <span [class.is-active]="activeAccount() && !activeSnapshot()">刷新</span>
+      <span [class.is-active]="activeSnapshot() && !activeAccountDiagnosis()">诊断</span>
+      <span [class.is-active]="activeAccountDiagnosis()">执行</span>
+    </div>
+
+    <nav class="workspace-tabs" aria-label="IP 操盘工作区">
+      <button
+        type="button"
+        *ngFor="let tab of workspaceTabs(); trackBy: trackById"
+        [class.is-active]="activeWorkspace() === tab.id"
+        (click)="setWorkspace(tab.id)">
+        <strong>{{ tab.label }}</strong>
+        <span>{{ tab.hint }}</span>
+      </button>
+    </nav>
+
+    <section class="account-monitor-panel evidence-summary-panel" [hidden]="activeWorkspace() !== 'overview' && activeWorkspace() !== 'evidence'" *ngIf="activeSnapshot() as snapshot">
+      <header class="account-monitor-panel__head">
+        <div>
+          <span class="ip-kicker">{{ activeWorkspace() === 'evidence' ? '账号作品库' : '重点诊断作品' }}</span>
+          <h3>{{ activeWorkspace() === 'evidence' ? '账号历史作品与分析' : '账号内容与数据' }}</h3>
+          <p>
+            {{ accountWorkLibraryProgress(snapshot) }},已采集 {{ snapshotCommentCount(snapshot) }} 条重点评论。
+            {{ activeWorkspace() === 'evidence' ? '这里可以加载更多历史作品,并对任意作品生成或重新生成作品报告。' : '默认优先展示互动和采样价值更高的作品。' }}
+          </p>
         </div>
-        <p class="ip-error" *ngIf="generationError()">{{ generationError() }}</p>
-        <div class="ip-actions">
-          <button class="btn" type="button" (click)="step.set(3)">上一步</button>
-          <button class="btn btn-primary" type="button" [disabled]="!canGenerate()" (click)="generatePlan()">
-            {{ isGenerating() ? '生成中...' : '生成 IP 起步方案' }}
+        <div class="account-monitor-panel__actions">
+          <button class="btn btn-sm" type="button" *ngIf="activeWorkspace() !== 'evidence'" (click)="setWorkspace('evidence')">进入账号作品库</button>
+          <button
+            class="btn btn-sm"
+            type="button"
+            *ngIf="activeWorkspace() === 'evidence'"
+            [disabled]="accountWorksLoadingMore()"
+            (click)="loadMoreAccountWorks()">
+            {{ accountWorkLibraryMoreHint(snapshot) }}
           </button>
+          <span class="account-monitor-source" *ngIf="activeWorkspace() === 'evidence'">
+            数据来源:抖音公开主页<br>
+            刷新时间:{{ snapshot.capturedAt | date:'MM-dd HH:mm' }}
+          </span>
         </div>
-      </section>
+      </header>
+      <p class="account-monitor-page-note" *ngIf="activeWorkspace() === 'evidence' && snapshot.workPageInfo?.pageStateReliable === false">
+        本次作品列表还在补齐中,已先展示当前可同步到的公开作品;后续可刷新账号或继续加载更多作品。
+      </p>
+      <div class="account-work-library-toolbar" *ngIf="activeWorkspace() === 'evidence'">
+        <div>
+          <span class="ip-kicker">排序依据</span>
+          <strong>{{ accountWorkSortLabel() }}</strong>
+        </div>
+        <div class="account-work-sort-buttons" role="group" aria-label="账号作品排序">
+          <button
+            type="button"
+            *ngFor="let option of accountWorkSortOptions; trackBy: trackById"
+            [class.is-active]="accountWorkSortMode() === option.id"
+            (click)="setAccountWorkSortMode(option.id)">
+            <strong>{{ option.label }}</strong>
+            <span>{{ option.hint }}</span>
+          </button>
+        </div>
+      </div>
 
-      <section class="ip-result" *ngIf="step() === 5 && activePlan() as plan">
-        <div class="ip-result-head ip-result-hero">
-          <div>
-            <span class="ip-kicker">IP 起步方案</span>
-            <h2>{{ plan.diagnosis?.positioningStatement || '定位一句话待补充' }}</h2>
-            <p>{{ planStatusHint(plan) }}</p>
+      <div class="account-monitor-profile">
+        <div class="account-monitor-avatar">
+          <img
+            *ngIf="snapshot.profile.avatarUrl"
+            [src]="snapshot.profile.avatarUrl"
+            [alt]="snapshot.profile.nickname"
+            referrerpolicy="no-referrer"
+            (error)="handleAccountProfileAvatarError(snapshot)"
+          >
+          <span *ngIf="!snapshot.profile.avatarUrl">{{ (snapshot.profile.nickname || '账').slice(0, 1) }}</span>
+        </div>
+        <div class="account-monitor-profile__identity">
+          <strong>{{ snapshot.profile.nickname || '未识别账号昵称' }}</strong>
+          <p>{{ snapshot.profile.signature || '主页暂未提供简介' }}</p>
+        </div>
+        <dl class="account-monitor-stats">
+          <div><dt>粉丝</dt><dd>{{ profileMetricLabel(snapshot.profile.followerCount) }}</dd></div>
+          <div><dt>获赞</dt><dd>{{ profileMetricLabel(snapshot.profile.totalFavorited) }}</dd></div>
+          <div><dt>关注</dt><dd>{{ profileMetricLabel(snapshot.profile.followingCount) }}</dd></div>
+          <div><dt>作品</dt><dd>{{ profileMetricLabel(snapshot.profile.awemeCount, snapshot.works.length) }}</dd></div>
+        </dl>
+      </div>
+
+      <div
+        class="account-monitor-work-list evidence-work-grid"
+        [class.evidence-work-grid--full]="activeWorkspace() === 'evidence'">
+        <article class="evidence-work-card" *ngFor="let work of accountWorksForWorkspace(snapshot); trackBy: trackById">
+          <div class="account-monitor-work__summary">
+            <div class="account-monitor-work__cover" aria-hidden="true">
+              <span>{{ workCoverLabel(work) }}</span>
+              <img
+                *ngIf="work.coverUrl"
+                [src]="work.coverUrl"
+                alt=""
+                width="72"
+                height="96"
+                loading="lazy"
+                referrerpolicy="no-referrer"
+                (error)="handleWorkCoverError(work)">
+            </div>
+            <div class="account-monitor-work__title">
+              <span>{{ work.publishTime ? (work.publishTime | date:'MM-dd HH:mm') : '发布时间未知' }}</span>
+              <strong>{{ work.title || work.desc || '未识别作品文案' }}</strong>
+              <small *ngIf="work.isDeepSampled">已优先深挖 {{ work.comments.length }} 条评论</small>
+              <small *ngIf="!work.isDeepSampled">未进入本次重点评论采样</small>
+            </div>
+            <dl>
+              <div><dt>播放</dt><dd>{{ work.metrics.playCount || 0 | number }}</dd></div>
+              <div><dt>点赞</dt><dd>{{ work.metrics.likeCount || 0 | number }}</dd></div>
+              <div><dt>评论</dt><dd>{{ work.metrics.commentCount || 0 | number }}</dd></div>
+              <div><dt>收藏</dt><dd>{{ work.metrics.collectCount || 0 | number }}</dd></div>
+              <div><dt>分享</dt><dd>{{ work.metrics.shareCount || 0 | number }}</dd></div>
+            </dl>
           </div>
-          <div class="ip-actions">
-            <button class="btn" type="button" (click)="step.set(1)">补充资料</button>
-            <button class="btn" type="button" [disabled]="plan.status !== 'ready'" (click)="navigateToTopicPool.emit()">去选题池</button>
-            <button class="btn" type="button" (click)="syncMissingMaterials()">同步素材待办</button>
-            <button class="btn" type="button" (click)="openIpMaterialLibrary()">去素材库</button>
-            <div class="ip-export">
-              <button class="btn" type="button" (click)="exportMenuOpen.set(!exportMenuOpen())">导出 Markdown</button>
-              <div class="ip-export-menu" *ngIf="exportMenuOpen()">
-                <button type="button" (click)="exportMarkdown('full')">完整方案</button>
-                <button type="button" (click)="exportMarkdown('client')">客户沟通版</button>
-                <button type="button" (click)="exportMarkdown('execution')">执行版</button>
-              </div>
+          <div class="work-evidence-strip" *ngIf="showWorkEvidenceStrip(work) && workEvidenceSummary(work) as summary">
+            <div>
+              <span class="ip-kicker">{{ workEvidenceKicker(work) }}</span>
+              <strong>{{ summary.title }}</strong>
+              <small>{{ summary.meta }}</small>
+            </div>
+            <button class="btn btn-sm" type="button" (click)="openWorkEvidence(work)">
+              {{ workEvidenceButtonLabel(work) }}
+            </button>
+          </div>
+          <div class="work-evidence-strip work-analysis-strip" *ngIf="!workAnalysisRunForWork(work)">
+            <div>
+              <span class="ip-kicker">作品分析报告</span>
+              <strong>{{ isAccountWorkAnalyzing(work) ? '正在生成作品报告' : '尚未分析此作品' }}</strong>
+              <small>{{ work.awemeId ? '可单独提取文字稿并结合评论生成 LLM 作品报告。' : '缺少视频 ID,暂不能分析。' }}</small>
+            </div>
+            <button
+              class="btn btn-sm btn-primary"
+              type="button"
+              [disabled]="!canStartAccountWorkAnalysis(work)"
+              (click)="analyzeAccountWorkFromLibrary(work)">
+              {{ isAccountWorkAnalyzing(work) ? '分析中...' : '分析此作品' }}
+            </button>
+          </div>
+          <div class="work-evidence-strip work-analysis-strip" *ngIf="workAnalysisRunForWork(work) as run">
+            <div>
+              <span class="ip-kicker">作品分析报告</span>
+              <strong>{{ workAnalysisStatusLabel(run.status) }}</strong>
+              <small>{{ workAnalysisRunDetail(run) }}</small>
+            </div>
+            <div class="work-analysis-actions">
+              <button class="btn btn-sm" type="button" *ngIf="run.report" (click)="openWorkReport(work)">
+                查看作品报告
+              </button>
+              <button
+                class="btn btn-sm"
+                type="button"
+                [disabled]="isWorkAnalysisRunActive(run) || !canStartAccountWorkAnalysis(work)"
+                (click)="analyzeAccountWorkFromLibrary(work, 'retry')">
+                {{ isWorkAnalysisRunActive(run) || isAccountWorkAnalyzing(work) ? '分析中...' : '重新分析' }}
+              </button>
             </div>
-            <button class="btn btn-primary" type="button" [disabled]="plan.status !== 'ready' || !productionReadyTopics().length" (click)="syncHighPriorityTopics()">同步可制作选题</button>
           </div>
+        </article>
+      </div>
+      <div class="account-monitor-load-more" *ngIf="activeWorkspace() === 'evidence'">
+        <button
+          class="btn btn-sm"
+          type="button"
+          [disabled]="accountWorksLoadingMore()"
+          (click)="loadMoreAccountWorks()">
+          {{ accountWorkLibraryMoreHint(snapshot) }}
+        </button>
+        <small>{{ accountWorkLibraryMoreNote(snapshot) }}</small>
+      </div>
+    </section>
+
+    <article class="account-positioning-report" [hidden]="activeWorkspace() !== 'overview' && activeWorkspace() !== 'positioning'" *ngIf="accountPositioningReport() as report">
+      <header>
+        <div>
+          <span class="ip-kicker">账号定位报告</span>
+          <h3>{{ report.title }}</h3>
+          <p>
+            <strong [class]="'account-positioning-report__badge account-positioning-report__badge--' + report.sourceMode">{{ report.sourceBadge }}</strong>
+            {{ report.source }}{{ report.updatedAt ? ' / ' + (report.updatedAt | date:'MM-dd HH:mm') : '' }}
+          </p>
         </div>
-        <p class="ip-error" *ngIf="generationError()">{{ generationError() }}</p>
-        <p class="ip-success" *ngIf="materialSyncMessage()">{{ materialSyncMessage() }}</p>
-        <p class="ip-success" *ngIf="productionEntryMessage()">{{ productionEntryMessage() }}</p>
+        <button class="btn btn-sm" type="button" *ngIf="activePositioningProposal() as proposal" (click)="openEvidenceDrawer(proposal.evidenceItemIds, '账号定位依据')">
+          查看定位依据
+        </button>
+      </header>
+      <p class="account-positioning-report__fallback" *ngIf="report.fallbackNote">{{ report.fallbackNote }}</p>
+      <div class="account-positioning-report__meta">
+        <span>数据范围 <strong>{{ report.dataScopeLabel }}</strong></span>
+        <span>作品报告 <strong>{{ report.workReportLabel }}</strong></span>
+        <span>判断置信度 <strong>{{ report.confidenceLabel }}</strong></span>
+      </div>
+      <div class="account-positioning-report__grid">
+        <section>
+          <span>当前账号像什么 IP</span>
+          <strong>{{ report.currentIpGuess }}</strong>
+        </section>
+        <section>
+          <span>实际吸引的人群</span>
+          <strong>{{ report.targetAudience }}</strong>
+        </section>
+        <section>
+          <span>用户为什么持续关注</span>
+          <strong>{{ report.followReason }}</strong>
+        </section>
+        <section>
+          <span>当前最大阻力</span>
+          <strong>{{ report.blocker }}</strong>
+        </section>
+      </div>
+      <div class="account-positioning-report__llm">
+        <section>
+          <h4>高互动内容共性</h4>
+          <p>{{ report.highInteractionPattern }}</p>
+        </section>
+        <section>
+          <h4>评论痛点归纳</h4>
+          <p>{{ report.commentPainSummary }}</p>
+        </section>
+        <section>
+          <h4>定位偏差判断</h4>
+          <p>{{ report.positioningMismatch }}</p>
+        </section>
+      </div>
+      <div class="account-positioning-report__directions" *ngIf="report.directionSignals.length">
+        <section *ngFor="let item of report.directionSignals">
+          <span>{{ item.roleLabel }}</span>
+          <strong>{{ item.title }}</strong>
+          <p>{{ item.body }}</p>
+        </section>
+      </div>
+      <div class="account-positioning-report__lists">
+        <section>
+          <h4>内容支柱</h4>
+          <ul>
+            <li *ngFor="let item of report.contentPillars">{{ item }}</li>
+            <li *ngIf="!report.contentPillars.length">等待账号诊断生成</li>
+          </ul>
+        </section>
+        <section>
+          <h4>表达边界</h4>
+          <ul>
+            <li *ngFor="let item of report.boundaries">{{ item }}</li>
+            <li *ngIf="!report.boundaries.length">暂无明确禁区,建议确认定位后补充。</li>
+          </ul>
+        </section>
+        <section>
+          <h4>证据与缺口</h4>
+          <ul>
+            <li *ngFor="let item of report.evidenceSummary">{{ item }}</li>
+            <li *ngFor="let item of report.evidenceGaps">{{ item }}</li>
+            <li *ngIf="!report.evidenceSummary.length">等待作品报告和评论样本进入诊断。</li>
+          </ul>
+        </section>
+      </div>
+      <p class="account-positioning-report__next">下一步:{{ report.nextAction }}</p>
+    </article>
+
+    <details class="account-intent-panel" [hidden]="activeWorkspace() !== 'overview'" [open]="!activeAccount()?.intendedTrack">
+      <summary>补充想做的赛道、人设和目标受众</summary>
+      <p>新号或作品样本不足时,这些信息会作为待验证假设;老号则用于判断“想做的定位”和真实内容是否一致。</p>
+      <div class="ip-form-grid">
+        <label>想做的赛道
+          <input [ngModel]="accountIntentDraft().intendedTrack" (ngModelChange)="updateAccountIntentDraft({intendedTrack: $event})" placeholder="例如:传统行业老板 IP">
+        </label>
+        <label>期望人设
+          <input [ngModel]="accountIntentDraft().intendedPersona" (ngModelChange)="updateAccountIntentDraft({intendedPersona: $event})" placeholder="例如:懂业务的增长顾问">
+        </label>
+        <label>目标受众
+          <input [ngModel]="accountIntentDraft().intendedAudience" (ngModelChange)="updateAccountIntentDraft({intendedAudience: $event})" placeholder="例如:传统行业中小企业老板">
+        </label>
+      </div>
+      <button class="btn btn-sm btn-primary" type="button" [disabled]="!activeAccount()" (click)="saveAccountIntent()">保存账号运营假设</button>
+    </details>
+
+    <article class="account-diagnosis-panel" [hidden]="activeWorkspace() !== 'overview'" *ngIf="activeAccountDiagnosis() as diagnosis">
+      <header>
+        <div>
+          <span class="ip-kicker">账号诊断摘要</span>
+          <h3>{{ diagnosis.mode === 'data_diagnosis' ? '基于真实作品与评论的账号判断' : '样本不足时的起号假设' }}</h3>
+        </div>
+        <small>{{ diagnosis.updatedAt | date:'MM-dd HH:mm' }} / 评论样本 {{ diagnosis.commentSampleCount }} 条</small>
+      </header>
+      <div class="account-score-grid">
+        <span>定位清晰度 <strong>{{ diagnosis.scores.positioningClarity }}</strong></span>
+        <span>内容结构力 <strong>{{ diagnosis.scores.contentStructure }}</strong></span>
+        <span>互动转化力 <strong>{{ diagnosis.scores.interactionConversion }}</strong></span>
+        <span>账号辨识度 <strong>{{ diagnosis.scores.recognizability }}</strong></span>
+        <span>增长可持续性 <strong>{{ diagnosis.scores.sustainability }}</strong></span>
+      </div>
+      <div class="strategy-report-meta" *ngIf="latestStrategyReport() as report">
+        <section>
+          <span>本轮数据</span>
+          <strong>{{ report.dataScopeSummary?.workCount || 0 }} 条作品 / {{ report.dataScopeSummary?.commentCount || 0 }} 条评论</strong>
+        </section>
+        <section>
+          <span>判断置信度</span>
+          <strong>{{ confidenceLabel(report.confidenceLevel) }}</strong>
+        </section>
+        <section>
+          <span>已采用校准</span>
+          <strong>{{ report.appliedCalibrationSummary?.length || 0 }} 条</strong>
+        </section>
+        <section>
+          <span>仍缺</span>
+          <strong>{{ report.evidenceGaps?.length ? (report.evidenceGaps || []).join('、') : '暂无关键缺口' }}</strong>
+        </section>
+      </div>
+      <div class="account-report-cards">
+        <section
+          *ngFor="let item of diagnosisReportCards(diagnosis)"
+          [class]="'account-report-card account-report-card--' + item.tone">
+          <span>{{ item.label }}</span>
+          <strong>{{ item.title }}</strong>
+          <p>{{ item.body }}</p>
+        </section>
+      </div>
+    </article>
 
-        <div class="ip-failure-panel" *ngIf="plan.status === 'failed' || planFailureCode(plan)">
+    <section class="account-calibration-panel" [hidden]="activeWorkspace() !== 'overview'" *ngIf="activePlan() as plan">
+      <header>
+        <div>
+          <span class="ip-kicker">账号校准</span>
+          <h3>把你的判断写回下一轮分析</h3>
+          <p>这里保存的是运营判断:哪些定位准确、哪些话题不要再做、哪些表达要避开。下一次重新分析会继承这些约束。</p>
+        </div>
+        <small *ngIf="latestAccountCalibration() as calibration">最近校准:{{ calibration.updatedAt | date:'MM-dd HH:mm' }}</small>
+      </header>
+      <div class="calibration-summary" *ngIf="latestAccountCalibration() as calibration">
+        <strong>{{ calibration.accuracy === 'accurate' ? '判断准确' : calibration.accuracy === 'partial' ? '部分准确' : '判断不准确' }}</strong>
+        <span>{{ calibration.positioningNotes || calibration.operatorExperienceNotes || '已作为下一轮分析约束。' }}</span>
+      </div>
+      <div class="ip-form-grid">
+        <label>诊断准确度
+          <select [ngModel]="accountCalibrationDraft().accuracy" (ngModelChange)="updateAccountCalibrationDraft({accuracy: $event})">
+            <option value="accurate">准确</option>
+            <option value="partial">部分准确</option>
+            <option value="wrong">不准确</option>
+          </select>
+        </label>
+        <label>目标受众修正
+          <input [ngModel]="accountCalibrationDraft().targetAudienceNotes" (ngModelChange)="updateAccountCalibrationDraft({targetAudienceNotes: $event})" placeholder="例如:更偏传统行业老板">
+        </label>
+        <label>定位/关注理由修正
+          <input [ngModel]="accountCalibrationDraft().positioningNotes" (ngModelChange)="updateAccountCalibrationDraft({positioningNotes: $event})" placeholder="例如:强调落地增长,而不是工具教程">
+        </label>
+        <label>人设修正
+          <input [ngModel]="accountCalibrationDraft().personaNotes" (ngModelChange)="updateAccountCalibrationDraft({personaNotes: $event})" placeholder="例如:懂业务的增长顾问">
+        </label>
+        <label>不要再推荐的话题
+          <input [ngModel]="accountCalibrationDraft().forbiddenTopicsText" (ngModelChange)="updateAccountCalibrationDraft({forbiddenTopicsText: $event})" placeholder="用逗号或换行分隔">
+        </label>
+        <label>不要使用的表达
+          <input [ngModel]="accountCalibrationDraft().forbiddenExpressionsText" (ngModelChange)="updateAccountCalibrationDraft({forbiddenExpressionsText: $event})" placeholder="用逗号或换行分隔">
+        </label>
+      </div>
+      <label class="calibration-notes">老号经验补充
+        <textarea rows="3" [ngModel]="accountCalibrationDraft().operatorExperienceNotes" (ngModelChange)="updateAccountCalibrationDraft({operatorExperienceNotes: $event})" placeholder="例如:这个号过去方法论内容容易收藏,但纯工具清单转粉差。"></textarea>
+      </label>
+      <button class="btn btn-sm btn-primary" type="button" [disabled]="!activeAccount()" (click)="saveManualAccountCalibration()">保存账号校准</button>
+    </section>
+
+    <div class="positioning-proposal-panel positioning-proposal-panel--compact" [hidden]="activeWorkspace() !== 'overview' && activeWorkspace() !== 'positioning'" *ngIf="activePositioningProposal() as proposal">
+        <div class="positioning-proposal-summary">
+          <span class="ip-kicker">定位提案摘要</span>
+          <h3>{{ positioningProposalDraft().persona || proposal.proposedVersion.persona }}</h3>
+          <dl>
+            <div>
+              <dt>目标人群</dt>
+              <dd>{{ positioningProposalDraft().targetAudience || proposal.proposedVersion.targetAudience }}</dd>
+            </div>
+            <div>
+              <dt>关注理由</dt>
+              <dd>{{ positioningProposalDraft().followReason || proposal.proposedVersion.followReason }}</dd>
+            </div>
+            <div>
+              <dt>证据数量</dt>
+              <dd>{{ proposal.evidenceItemIds.length }} 条</dd>
+            </div>
+          </dl>
+          <p>{{ proposal.reason }}</p>
+          <p class="positioning-evidence-note" *ngIf="activeSnapshot() as snapshot">
+            定位依据:最近 {{ snapshot.works.length }} 条作品、{{ activeAccountDiagnosis().commentSampleCount || 0 }} 条重点评论。
+            作品文案和评论仅作为证据,系统会先归纳主题与痛点,不会直接复制为定位结论。
+          </p>
+        </div>
+        <div class="positioning-edit-panel" *ngIf="positioningEditOpen()">
+        <div class="ip-form-grid">
+          <label>当前推断目标人群
+            <input [ngModel]="positioningProposalDraft().targetAudience" (ngModelChange)="updatePositioningProposalDraft({targetAudience: $event})">
+          </label>
+          <label>建议账号角色
+            <input [ngModel]="positioningProposalDraft().persona" (ngModelChange)="updatePositioningProposalDraft({persona: $event})">
+          </label>
+        </div>
+        <label class="positioning-follow-reason">用户为什么值得持续关注
+          <span>这里应是一句简洁的账号价值承诺,不是视频文案或文字稿。</span>
+          <textarea rows="4" maxlength="160" [ngModel]="positioningProposalDraft().followReason" (ngModelChange)="updatePositioningProposalDraft({followReason: $event})"></textarea>
+        </label>
+        <div class="ip-form-grid">
+          <label>内容支柱
+            <textarea rows="3" [ngModel]="positioningProposalDraft().contentPillarsText" (ngModelChange)="updatePositioningProposalDraft({contentPillarsText: $event})"></textarea>
+          </label>
+          <label>表达风格
+            <textarea rows="3" [ngModel]="positioningProposalDraft().expressionStyleText" (ngModelChange)="updatePositioningProposalDraft({expressionStyleText: $event})"></textarea>
+          </label>
+        </div>
+        <label>明确不做什么
+          <textarea rows="2" [ngModel]="positioningProposalDraft().boundariesText" (ngModelChange)="updatePositioningProposalDraft({boundariesText: $event})"></textarea>
+        </label>
+      </div>
+      <div class="ip-actions">
+        <button class="btn btn-sm" type="button" (click)="openEvidenceDrawer(proposal.evidenceItemIds, 'IP定位提案证据')">查看定位证据</button>
+        <button class="btn" type="button" (click)="positioningEditOpen.set(!positioningEditOpen())">{{ positioningEditOpen() ? '收起编辑' : '编辑定位' }}</button>
+        <button class="btn" type="button" *ngIf="positioningEditOpen()" (click)="savePositioningProposalRevision(proposal)">保存修改稿</button>
+        <button class="btn btn-primary" type="button" (click)="acceptPositioningProposal(proposal)">确认生效</button>
+        <button class="btn" type="button" (click)="rejectPositioningProposal(proposal)">拒绝提案</button>
+      </div>
+    </div>
+
+    <div class="priority-task-board" [hidden]="activeWorkspace() !== 'overview' && activeWorkspace() !== 'tasks'" *ngIf="operationTasks().length">
+      <section class="priority-task-column">
+        <h3>今天必须做</h3>
+        <article *ngFor="let task of tasksInColumn('today'); trackBy: trackById">
+          <strong>{{ task.title }}</strong>
+          <p>{{ task.reason }}</p>
+          <small>增长影响 {{ task.growthImpactScore }}</small>
+          <button class="btn btn-sm" type="button" *ngIf="task.relatedEvidenceIds.length" (click)="openEvidenceDrawer(task.relatedEvidenceIds, '任务来源证据')">查看证据</button>
+          <button class="btn btn-sm" type="button" (click)="moveTask(task, 'done')">完成</button>
+        </article>
+      </section>
+      <section class="priority-task-column">
+        <h3>本周要做</h3>
+        <article *ngFor="let task of tasksInColumn('this_week'); trackBy: trackById">
+          <strong>{{ task.title }}</strong>
+          <p>{{ task.reason }}</p>
+          <small>增长影响 {{ task.growthImpactScore }}</small>
+          <button class="btn btn-sm" type="button" *ngIf="task.relatedEvidenceIds.length" (click)="openEvidenceDrawer(task.relatedEvidenceIds, '任务来源证据')">查看证据</button>
+          <button class="btn btn-sm" type="button" (click)="moveTask(task, 'today')">提到今天</button>
+        </article>
+      </section>
+      <section class="priority-task-column">
+        <h3>后续优化</h3>
+        <article *ngFor="let task of tasksInColumn('later'); trackBy: trackById">
+          <strong>{{ task.title }}</strong>
+          <p>{{ task.reason }}</p>
+          <small>增长影响 {{ task.growthImpactScore }}</small>
+          <button class="btn btn-sm" type="button" *ngIf="task.relatedEvidenceIds.length" (click)="openEvidenceDrawer(task.relatedEvidenceIds, '任务来源证据')">查看证据</button>
+          <button class="btn btn-sm" type="button" (click)="moveTask(task, 'this_week')">排到本周</button>
+        </article>
+      </section>
+      <section class="priority-task-column">
+        <h3>已完成</h3>
+        <article *ngFor="let task of tasksInColumn('done'); trackBy: trackById">
+          <strong>{{ task.title }}</strong>
+          <p>{{ task.reason }}</p>
+          <small>增长影响 {{ task.growthImpactScore }}</small>
+          <button class="btn btn-sm" type="button" *ngIf="task.relatedEvidenceIds.length" (click)="openEvidenceDrawer(task.relatedEvidenceIds, '任务来源证据')">查看证据</button>
+        </article>
+      </section>
+    </div>
+
+    <p class="content-direction-note" [hidden]="activeWorkspace() !== 'directions'" *ngIf="contentDirections().length">
+      当前内容方向来自 {{ accountStrategySourceLabel() }}:账号作品、主页信息、重点评论和爆款/对标证据会先被归纳为 IP 定位,再生成方向和选题。主页简介、评论和爆款文案只作为证据来源,不应直接复制成方向。
+    </p>
+
+    <section class="ip-positioning-bridge-panel" [hidden]="activeWorkspace() !== 'directions'">
+      <div class="ip-positioning-result-card" [class.is-missing]="currentIpPositioning().status === 'missing'">
+        <span class="ip-kicker">当前IP定位结果</span>
+        <h3>{{ currentIpPositioning().label }}</h3>
+        <dl>
+          <div>
+            <dt>账号角色</dt>
+            <dd>{{ currentIpPositioning().persona }}</dd>
+          </div>
+          <div>
+            <dt>目标人群</dt>
+            <dd>{{ currentIpPositioning().targetAudience }}</dd>
+          </div>
           <div>
-            <span class="ip-kicker">失败原因</span>
-            <strong>{{ planFailureCode(plan) || 'unknown' }}</strong>
-            <p>{{ failureMessage(planFailureCode(plan)) }}</p>
+            <dt>关注理由</dt>
+            <dd>{{ currentIpPositioning().followReason }}</dd>
           </div>
+        </dl>
+        <p>{{ currentIpPositioning().source }}</p>
+      </div>
+
+      <div class="ip-viral-fit-panel">
+        <div class="ip-viral-fit-panel__head">
           <div>
-            <span>失败阶段</span>
-            <strong>{{ planFailureStage(plan) }}</strong>
-            <p>{{ failureNextAction(planFailureCode(plan)) }}</p>
+            <span class="ip-kicker">博主监测 / 爆款分析适配</span>
+            <h3>结合当前IP后的方向和建议</h3>
+            <p>这里不直接复用爆款文案,只判断它的结构、评论触发点和用户问题,是否适合迁移到当前IP。</p>
           </div>
+          <button class="btn btn-sm" type="button" (click)="refreshSavedAnalyses()" [disabled]="savedAnalysisLoading()">
+            {{ savedAnalysisLoading() ? '加载中...' : '刷新爆款分析' }}
+          </button>
+        </div>
+
+        <div class="ip-viral-fit-empty" *ngIf="!ipViralAdaptationSuggestions().length">
+          <strong>暂无可适配的爆款/监测记录</strong>
+          <p>{{ currentIpPositioning().status === 'missing' ? '请先刷新账号并生成定位提案。' : '可先在博主监测中分析对标作品,或在爆款分析中保存记录,再回到这里查看与当前IP的适配建议。' }}</p>
+        </div>
+
+        <div class="ip-viral-fit-grid" *ngIf="ipViralAdaptationSuggestions().length">
+          <article *ngFor="let suggestion of ipViralAdaptationSuggestions(); trackBy: trackById">
+            <span>{{ suggestion.sourceLabel }} / {{ suggestion.fitDirection }}</span>
+            <h4>{{ suggestion.title }}</h4>
+            <p><strong>可借结构:</strong>{{ suggestion.reusablePattern }}</p>
+            <p><strong>IP适配:</strong>{{ suggestion.ipAdaptation }}</p>
+            <p><strong>建议选题:</strong>{{ suggestion.suggestedTopic }}</p>
+            <small>风险:{{ suggestion.risk }}</small>
+            <button
+              class="btn btn-sm evidence-link-button"
+              type="button"
+              *ngIf="suggestion.analysisId"
+              [disabled]="savedAnalysisLoading()"
+              (click)="bindViralSuggestionEvidence(suggestion)">
+              沉淀为当前IP证据
+            </button>
+          </article>
+        </div>
+        <p class="ip-success" *ngIf="savedAnalysisMessage()">{{ savedAnalysisMessage() }}</p>
+      </div>
+    </section>
+
+    <section class="direction-workspace" [hidden]="activeWorkspace() !== 'directions'" *ngIf="contentDirections().length">
+      <header>
+        <div>
+          <span class="ip-kicker">方向选题</span>
+          <h3>方向摘要</h3>
+          <p>先选择一个方向,再查看该方向下的选题、证据和制作动作。</p>
+        </div>
+      </header>
+
+      <div class="direction-workspace__layout">
+        <div class="direction-summary-list" role="list">
+          <article
+            class="direction-summary-card"
+            *ngFor="let direction of contentDirections(); trackBy: trackById"
+            [class.is-active]="selectedDirection().id === direction.id">
+            <button
+              class="direction-summary-card__select"
+              type="button"
+              (click)="selectDirection(direction)">
+              <span>{{ directionRoleLabel(direction.role) }}方向</span>
+              <strong>{{ direction.title }}</strong>
+              <small>{{ topicsForDirection(direction).length }} 个选题 / {{ evidenceCountLabel(directionEvidenceIds(direction)) }}</small>
+            </button>
+          </article>
         </div>
 
-        <div class="ip-summary-grid">
-          <section class="ip-summary-card">
-            <span>状态</span>
-            <strong>{{ planStatusLabel(plan.status) }}</strong>
-            <small>{{ plan.updatedAt | date:'yyyy-MM-dd HH:mm' }}</small>
+        <article class="selected-direction-panel" *ngIf="selectedDirection() as direction">
+          <span class="ip-kicker">选中方向</span>
+          <h3>{{ direction.title }}</h3>
+          <p>{{ direction.purpose }}</p>
+          <dl>
+            <div><dt>目标人群</dt><dd>{{ direction.targetAudience }}</dd></div>
+            <div><dt>更新建议</dt><dd>{{ direction.cadenceSuggestion }}</dd></div>
+            <div><dt>观察指标</dt><dd>{{ direction.observeMetrics.join('、') }}</dd></div>
+          </dl>
+          <small>来源证据:{{ evidenceCountLabel(directionEvidenceIds(direction)) }}</small>
+          <small *ngIf="direction.fitReason">适配当前IP:{{ direction.fitReason }}</small>
+          <small *ngIf="direction.risks?.length">避免照搬:{{ direction.risks?.join('、') }}</small>
+          <button
+            class="btn btn-sm evidence-link-button"
+            type="button"
+            [disabled]="!directionEvidenceIds(direction).length"
+            (click)="openEvidenceDrawer(directionEvidenceIds(direction), direction.title + ' · 来源证据')">
+            查看方向依据
+          </button>
+
+          <div class="selected-topic-list" *ngIf="topicsForDirection(direction).length">
+            <article
+              class="direction-topic-card"
+              [class.is-generating]="isTopicScriptGenerating(topic)"
+              [class.has-script]="!!topicScript(topic)"
+              *ngFor="let topic of topicsForDirection(direction); trackBy: trackById">
+              <strong>{{ topic.title }}</strong>
+              <p>{{ topic.userProblem || topic.contentGoal }}</p>
+              <small>目标人群:{{ topic.targetAudience || direction.targetAudience }}</small>
+              <small *ngIf="topicDirectEvidenceIds(topic).length; else inheritedDirectionEvidence">
+                本题专属依据:已关联 {{ topicDirectEvidenceIds(topic).length }} 条
+              </small>
+              <ng-template #inheritedDirectionEvidence>
+                <small>本题暂无专属依据,当前沿用所属方向的判断。</small>
+              </ng-template>
+              <small>可借结构:{{ displayText(topic.viralPatternRefs) }}</small>
+              <small>避免照搬:{{ topic.riskNote || '只借结构和用户问题,不复制原视频表达。' }}</small>
+              <div class="topic-actions">
+                <button
+                  *ngIf="topicDirectEvidenceIds(topic).length"
+                  class="btn btn-sm"
+                  type="button"
+                  (click)="openEvidenceDrawer(topicDirectEvidenceIds(topic), topic.title + ' · 本题依据')">
+                  查看本题依据
+                </button>
+                <button class="btn btn-sm" type="button" [disabled]="isTopicScriptGenerating(topic)" (click)="generateShotScriptForTopic(topic)">
+                  {{ topicScriptActionLabel(topic) }}
+                </button>
+                <button class="btn btn-sm" type="button" (click)="addTopicToCalendar(topic)">
+                  加入内容日历
+                </button>
+                <button class="btn btn-sm btn-primary" type="button" (click)="createPublishPackageForTopic(topic)">
+                  {{ publishPackageActionLabel(topic) }}
+                </button>
+              </div>
+            </article>
+          </div>
+          <p class="direction-topic-empty" *ngIf="!topicsForDirection(direction).length">
+            当前方向还没有可执行选题,请先重新分析账号定位或刷新工作台。
+          </p>
+        </article>
+      </div>
+    </section>
+
+    <div class="shot-script-modal-backdrop" *ngIf="activeShotScriptModal() as script" (click)="closeShotScriptModal()">
+      <section class="shot-script-modal" role="dialog" aria-modal="true" aria-label="分镜脚本内容" (click)="$event.stopPropagation()">
+        <header>
+          <div>
+            <span class="ip-kicker">分镜脚本</span>
+            <h3>{{ script.title || '分镜脚本' }}</h3>
+            <p>{{ script.scriptSummary || script.durationReason || '已生成可拍摄脚本,请人工复核后再进入发布包。' }}</p>
+          </div>
+          <div class="shot-script-modal__actions">
+            <button class="btn btn-sm" type="button" [disabled]="isShotScriptRegenerating(script)" (click)="regenerateShotScript(script)">
+              {{ isShotScriptRegenerating(script) ? '重新生成中...' : '重新生成' }}
+            </button>
+            <button class="shot-script-modal__close" type="button" aria-label="关闭分镜脚本弹窗" title="关闭" (click)="closeShotScriptModal()">×</button>
+          </div>
+        </header>
+
+        <div class="shot-script-modal__summary">
+          <section>
+            <span>建议时长</span>
+            <strong>{{ script.recommendedDurationSeconds || 60 }} 秒</strong>
           </section>
-          <section class="ip-summary-card" *ngIf="plan.qualityCheck as q">
-            <span>质量分</span>
-            <strong>{{ q.overallScore }}/100</strong>
-            <small>{{ q.passed ? '已过质量门槛' : '未过质量门槛' }}</small>
+          <section>
+            <span>质量状态</span>
+            <strong>{{ scriptQualityLabel(script.qualityLevel) }}</strong>
+          </section>
+        </div>
+
+        <div class="shot-script-modal__core">
+          <section>
+            <span>开头钩子</span>
+            <p>{{ script.hook }}</p>
           </section>
-          <section class="ip-summary-card">
-            <span>选题</span>
-            <strong>{{ plan.topics.length }}</strong>
-            <small>可制作 {{ productionReadyTopics().length }} / 需打磨 {{ polishFirstTopics().length }} / 需素材 {{ materialNeededTopics().length }}</small>
+          <section>
+            <span>核心观点</span>
+            <p>{{ script.viewpoint }}</p>
           </section>
-          <section class="ip-summary-card">
-            <span>脚本</span>
-            <strong>{{ fullScripts().length }} 完整 / {{ outlineScripts().length }} 大纲</strong>
-            <small>可拍摄 {{ recordableScripts().length }}</small>
+          <section>
+            <span>结尾引导</span>
+            <p>{{ script.closingCta }}</p>
           </section>
         </div>
 
-        <div class="ip-quality-audit" *ngIf="qualityAudit(plan) as audit">
-          <header>
-            <div>
-              <span class="ip-kicker">P2A 核心质量审计</span>
-              <strong>{{ audit.overallScore }}/100</strong>
-              <small>{{ audit.productionGate.reason }}</small>
-            </div>
-            <div class="ip-gate-tags">
-              <span [class.is-pass]="audit.productionGate.canEnterTopicPool">选题池</span>
-              <span [class.is-pass]="audit.productionGate.canCreateDraft">内容制作</span>
-              <span [class.is-pass]="audit.productionGate.canExportExecution">执行导出</span>
-            </div>
-            <button class="btn btn-sm" type="button" [disabled]="!plan.benchmarkEvidence?.length || evidenceLoading()" (click)="generateEvidenceBasedSuggestions()">
-              {{ evidenceLoading() ? '生成中...' : '生成增强建议' }}
-            </button>
-          </header>
-          <div class="ip-quality-dimensions">
-            <section *ngFor="let item of audit.dimensions; trackBy: trackByIndex" [class.is-blocked]="item.blockingIssues.length">
-              <strong>{{ item.label }} <span>{{ item.score }}</span></strong>
-              <p>{{ item.conclusion }}</p>
-              <small *ngIf="item.improvementActions.length">{{ displayText(item.improvementActions) }}</small>
-            </section>
-          </div>
+        <div class="shot-script-modal__shots" *ngIf="script.shotList?.length">
+          <article *ngFor="let shot of script.shotList; trackBy: trackById">
+            <strong>{{ shot.startSecond }}-{{ shot.endSecond }} 秒|{{ shot.segmentTitle }}</strong>
+            <p>{{ shot.visualDesign }}</p>
+            <small><strong>口播台词:</strong>{{ shot.narration }}</small>
+            <small><strong>画面重点字:</strong>{{ shot.subtitle }}</small>
+            <small>动作:{{ shot.actionNotes }}</small>
+            <small>音效:{{ shot.audioNotes }}</small>
+          </article>
         </div>
 
-        <div class="ip-readiness-panel" [class.is-blocked]="plan.status !== 'ready'">
-          <div class="ip-readiness-main">
-            <span class="ip-kicker">{{ plan.status === 'ready' ? '可进入生产链路' : '当前仍是待完善草稿' }}</span>
-            <strong>{{ plan.status === 'ready' ? '方案已通过质量门槛,可以继续同步可制作选题。' : '先补齐关键资料,再判断是否进入选题池和生产 Pipeline。' }}</strong>
-            <p>{{ plan.status === 'ready' ? '建议先同步可制作选题,进入选题池继续打磨脚本和制作。' : '页面下方内容可以阅读和编辑,但不能当成最终执行方案;优先处理阻断问题和待补充资料。' }}</p>
-          </div>
-          <div class="ip-readiness-list" *ngIf="plan.status !== 'ready'">
-            <section *ngIf="plan.qualityCheck?.blockingIssues?.length">
-              <strong>阻断问题</strong>
-              <p *ngFor="let issue of firstItems(plan.qualityCheck?.blockingIssues, 3)">{{ displayText(issue) }}</p>
-            </section>
-            <section *ngIf="plan.missingInputs.length">
-              <strong>待补资料</strong>
-              <p *ngFor="let item of firstItems(plan.missingInputs, 3)">{{ displayText(item.type) }}:{{ displayText(item.description) }}</p>
-            </section>
+        <footer>
+          <p class="shot-script-modal__hint">“画面重点字”不是逐字字幕,而是建议打在视频画面上的重点文案;逐字字幕仍以口播台词为准。</p>
+          <p><strong>风险提醒:</strong>{{ script.riskNote || '不要照搬对标账号话术,只迁移结构和用户问题。' }}</p>
+        </footer>
+      </section>
+    </div>
+
+    <div class="publish-package-modal-backdrop" *ngIf="activePublishPackageModal() as pkg" (click)="closePublishPackageModal()">
+      <section class="publish-package-modal" role="dialog" aria-modal="true" aria-label="发布包详情" (click)="$event.stopPropagation()">
+        <header>
+          <div>
+            <span class="ip-kicker">人工发布包</span>
+            <h3>{{ pkg.titleOptions[0] || '发布包详情' }}</h3>
+            <p>{{ evidencePlatformLabel(pkg.platform) }} / {{ pkg.accountName }} / {{ publishStatusLabel(pkg.status) }}</p>
           </div>
-        </div>
+          <button class="publish-package-modal__close" type="button" aria-label="关闭发布包" title="关闭" (click)="closePublishPackageModal()">×</button>
+        </header>
 
-        <article class="ip-card ip-section-card ip-supplement-panel">
-          <header>
+        <div class="publish-package-modal__body">
+          <section class="publish-package-modal__overview">
+            <div><span>建议排期</span><strong>{{ pkg.scheduleSuggestion || '待人工确定' }}</strong></div>
+            <div><span>时长与节奏</span><strong>{{ pkg.durationAndRhythm || '以执行脚本为准' }}</strong></div>
+            <div><span>发布方式</span><strong>人工确认后手动发布</strong></div>
+          </section>
+
+          <section class="publish-package-modal__polish" [class.is-failed]="pkg.polishSource === 'local_fallback'">
             <div>
-              <span class="ip-kicker">补充资料闭环</span>
-              <h3>把待补资料变成下一次质量检查的依据</h3>
-              <small class="ip-regeneration-meta">{{ supplementMessage() || '补充资料不会自动覆盖正文,保存后请手动重新质量检查或重生成相关模块。' }}</small>
+              <span>{{ pkg.polishSource === 'llm' ? 'AI 精修结果' : '发布包草稿' }}</span>
+              <strong>{{ pkg.polishSummary || '已根据选题和执行脚本生成规则草稿,正在等待 AI 结合账号证据精修。' }}</strong>
             </div>
-            <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('quality_check')">
-              {{ regeneratingStage() === 'quality_check' ? '检查中...' : '重新质量检查' }}
-            </button>
-            <button class="btn btn-sm" type="button" (click)="importFilledMaterials()">导入已补素材</button>
-          </header>
+            <small *ngIf="pkg.polishRationale?.length">{{ pkg.polishRationale?.join(';') }}</small>
+          </section>
 
-          <div class="ip-supplement-grid">
+          <div class="publish-package-modal__grid">
             <section>
-              <h4>新增补充资料</h4>
-              <div class="ip-form-grid">
-                <label>资料类型
-                  <select [ngModel]="supplementDraft().type" (ngModelChange)="updateSupplementDraft({type: $event})">
-                    <option *ngFor="let type of supplementTypes" [value]="type">{{ type }}</option>
-                  </select>
-                </label>
-                <label>关联待补资料
-                  <select [ngModel]="supplementDraft().relatedMissingInputId" (ngModelChange)="updateSupplementDraft({relatedMissingInputId: $event})">
-                    <option value="">不关联</option>
-                    <option *ngFor="let item of plan.missingInputs; let i = index" [value]="i + ':' + displayText(item.type)">
-                      {{ displayText(item.type) }}:{{ displayText(item.description) }}
-                    </option>
-                  </select>
-                </label>
-              </div>
-              <label>标题<input [ngModel]="supplementDraft().title" (ngModelChange)="updateSupplementDraft({title: $event})"></label>
-              <label>内容<textarea rows="4" [ngModel]="supplementDraft().content" (ngModelChange)="updateSupplementDraft({content: $event})"></textarea></label>
-              <div class="ip-actions">
-                <button class="btn btn-primary" type="button" [disabled]="!supplementDraft().title.trim() || !supplementDraft().content.trim()" (click)="addSupplementInput()">保存补充资料</button>
-              </div>
+              <h4>标题候选</h4>
+              <ol><li *ngFor="let title of pkg.titleOptions">{{ title }}</li></ol>
             </section>
-
             <section>
-              <h4>已补充资料</h4>
-              <p class="ip-empty" *ngIf="!plan.supplementInputs?.length">还没有补充资料。优先补客户案例、客户问题、服务前后对比和对标数据。</p>
-              <div class="ip-supplement-list" *ngIf="plan.supplementInputs?.length">
-                <article *ngFor="let item of plan.supplementInputs; trackBy: trackById">
-                  <label>类型
-                    <select [(ngModel)]="item.type" (change)="saveSupplementInput(item, {type: item.type})">
-                      <option *ngFor="let type of supplementTypes" [value]="type">{{ type }}</option>
-                    </select>
-                  </label>
-                  <label>标题<input [(ngModel)]="item.title" (change)="saveSupplementInput(item, {title: item.title})"></label>
-                  <label>内容<textarea rows="3" [(ngModel)]="item.content" (change)="saveSupplementInput(item, {content: item.content})"></textarea></label>
-                  <div class="ip-actions">
-                    <small>{{ item.updatedAt | date:'MM-dd HH:mm:ss' }}</small>
-                    <button class="btn btn-sm" type="button" (click)="deleteSupplementInput(item)">删除</button>
+              <h4>封面建议</h4>
+              <ul><li *ngFor="let cover of pkg.coverSuggestions">{{ cover }}</li></ul>
+            </section>
+          </div>
+
+          <section class="publish-package-modal__section">
+            <h4>发布文案</h4>
+            <p class="publish-package-modal__copy">{{ pkg.caption }}</p>
+          </section>
+
+          <div class="publish-package-modal__script-grid">
+            <section class="publish-package-modal__section">
+              <h4>分段口播稿</h4>
+              <div class="publish-package-modal__timed-script" *ngIf="publishPackageScriptShots(pkg) as shots">
+                <article *ngFor="let shot of shots; trackBy: trackById">
+                  <span class="publish-package-modal__time">{{ shot.startSecond }}–{{ shot.endSecond }} 秒</span>
+                  <div>
+                    <strong>{{ shot.segmentTitle }}</strong>
+                    <p>{{ shot.narration }}</p>
+                    <small>画面:{{ shot.visualDesign }}</small>
                   </div>
                 </article>
+                <div class="publish-package-modal__untimed" *ngIf="!shots.length">
+                  <p class="publish-package-modal__copy">{{ pkg.scriptText || '暂无完整口播稿,请先重新生成分镜脚本。' }}</p>
+                  <small>当前脚本没有分段时间。重新生成分镜脚本后,这里会按秒展示对应口播。</small>
+                </div>
               </div>
             </section>
+            <section class="publish-package-modal__section">
+              <h4>拍摄清单</h4>
+              <ul class="publish-package-modal__shooting-list"><li *ngFor="let item of pkg.shootingChecklist || []">{{ item }}</li></ul>
+              <p *ngIf="!pkg.shootingChecklist?.length">暂无拍摄清单,请先补齐脚本和素材。</p>
+            </section>
           </div>
-        </article>
 
-        <article class="ip-card ip-section-card ip-evidence-panel">
-          <header>
-            <div>
-              <span class="ip-kicker">P2C 对标证据</span>
-              <h3>抓取单条对标作品,回到机会地图、选题和脚本判断</h3>
-              <small class="ip-regeneration-meta">{{ evidenceMessage() || '当前只做用户触发的单条作品证据采集,不做无人值守批量抓取。短链需要先展开为 douyin.com/video/{id}。' }}</small>
-            </div>
-          </header>
-          <div class="ip-evidence-form">
-            <label>对标账号
-              <select [ngModel]="evidenceBenchmarkId()" (ngModelChange)="evidenceBenchmarkId.set($event)">
-                <option value="">不绑定账号</option>
-                <option *ngFor="let benchmark of benchmarkOptions(plan); trackBy: trackById" [value]="benchmark.id">{{ benchmark.name }}</option>
-              </select>
-            </label>
-            <label>作品链接或 awemeId
-              <input [ngModel]="evidenceUrl()" (ngModelChange)="evidenceUrl.set($event)" placeholder="https://www.douyin.com/video/..." />
-            </label>
-            <button class="btn btn-primary" type="button" [disabled]="!evidenceUrl().trim() || evidenceLoading()" (click)="fetchBenchmarkEvidence()">
-              {{ evidenceLoading() ? '采集中...' : '采集证据' }}
-            </button>
-          </div>
-          <div class="ip-evidence-form ip-evidence-form--saved">
-            <label>已保存爆款分析
-              <select [ngModel]="savedAnalysisId()" (ngModelChange)="savedAnalysisId.set($event)">
-                <option value="">请选择分析记录</option>
-                <option *ngFor="let analysis of savedAnalyses(); trackBy: trackById" [value]="analysis.id">
-                  {{ displayText(analysis.videoSnapshot.desc || analysis.awemeId) }}
-                </option>
-              </select>
-            </label>
-            <button class="btn" type="button" [disabled]="savedAnalysisLoading()" (click)="refreshSavedAnalyses()">
-              {{ savedAnalysisLoading() ? '加载中...' : '刷新分析记录' }}
-            </button>
-            <button class="btn btn-primary" type="button" [disabled]="!savedAnalysisId() || savedAnalysisLoading()" (click)="bindSavedAnalysisEvidence()">
-              绑定为证据
-            </button>
-          </div>
-          <p class="ip-success" *ngIf="savedAnalysisMessage()">{{ savedAnalysisMessage() }}</p>
-          <div class="ip-evidence-grid" *ngIf="plan.benchmarkEvidence?.length || plan.evidenceFetchJobs?.length">
+          <div class="publish-package-modal__grid">
             <section>
-              <h4>最近证据</h4>
-              <p class="ip-empty" *ngIf="!plan.benchmarkEvidence?.length">暂无已保存对标证据。</p>
-              <article *ngFor="let item of recentBenchmarkEvidence(plan); trackBy: trackById">
-                <strong>{{ displayText(item.title) }}</strong>
-                <span>{{ item.confidence }} / {{ displayText(item.authorName) }}</span>
-                <p>{{ displayText(item.summary) }}</p>
-                <small>{{ displayText(item.migrationSuggestion) }}</small>
-                <button class="btn btn-sm" type="button" (click)="generateEvidenceSuggestions(item)">生成打磨建议</button>
-              </article>
+              <h4>话题与评论引导</h4>
+              <p>{{ pkg.hashtags.join(' ') }}</p>
+              <p><strong>互动问题:</strong>{{ pkg.commentGuidance || '待人工补充' }}</p>
+              <p><strong>置顶评论:</strong>{{ pkg.pinnedCommentSuggestion || '待人工补充' }}</p>
             </section>
             <section>
-              <h4>采集记录</h4>
-              <p class="ip-empty" *ngIf="!plan.evidenceFetchJobs?.length">暂无采集记录。</p>
-              <article *ngFor="let job of recentEvidenceJobs(plan); trackBy: trackById" [class.is-failed]="job.status === 'failed'">
-                <strong>{{ evidenceJobStatusLabel(job.status) }} / {{ evidenceStrengthLabel(job.evidenceStrength) }}</strong>
-                <span>{{ job.awemeId || job.inputUrl }}</span>
-                <small>{{ job.updatedAt | date:'MM-dd HH:mm:ss' }}</small>
-                <p *ngIf="job.failureMessage">{{ job.failureMessage }}</p>
-              </article>
+              <h4>字幕与声音</h4>
+              <p><strong>画面重点字:</strong>{{ (pkg.subtitleHighlights || []).join('、') || '以执行脚本为准' }}</p>
+              <p><strong>声音建议:</strong>{{ pkg.audioSuggestion || '口播清晰优先' }}</p>
             </section>
           </div>
-          <div class="ip-evidence-suggestions" *ngIf="plan.evidenceSuggestions?.length">
-            <h4>证据打磨建议</h4>
-            <article *ngFor="let suggestion of recentEvidenceSuggestions(plan); trackBy: trackById" [class.is-applied]="suggestion.status === 'applied'" [class.is-dismissed]="suggestion.status === 'dismissed'">
-              <div>
-                <strong>{{ displayText(suggestion.title) }}</strong>
-                <span>{{ suggestionTargetLabel(suggestion.targetType) }} / {{ suggestion.status }}</span>
-              </div>
-              <p>{{ displayText(suggestion.rationale) }}</p>
-              <small>{{ displayText(suggestion.suggestedChange) }}</small>
-              <div class="ip-actions" *ngIf="suggestion.status === 'draft'">
-                <button class="btn btn-sm btn-primary" type="button" (click)="applyEvidenceSuggestion(suggestion)">应用</button>
-                <button class="btn btn-sm" type="button" (click)="dismissEvidenceSuggestion(suggestion)">忽略</button>
-              </div>
-            </article>
+
+          <div class="publish-package-modal__grid publish-package-modal__checks">
+            <section class="publish-package-modal__check-group">
+              <header><h4>物料检查</h4><span>发布前逐项确认</span></header>
+              <label class="publish-package-modal__check-row" *ngFor="let item of pkg.materialChecklist">
+                <input type="checkbox" [checked]="item.checked" (change)="updatePublishChecklist(pkg, 'materialChecklist', item.id, $any($event.target).checked)">
+                <span>{{ item.label }}</span>
+              </label>
+            </section>
+            <section class="publish-package-modal__check-group">
+              <header><h4>风险检查</h4><span>确认后再手动发布</span></header>
+              <label class="publish-package-modal__check-row" *ngFor="let item of pkg.riskChecklist">
+                <input type="checkbox" [checked]="item.checked" (change)="updatePublishChecklist(pkg, 'riskChecklist', item.id, $any($event.target).checked)">
+                <span>{{ item.label }}</span>
+              </label>
+            </section>
           </div>
-        </article>
 
-        <div class="ip-stage-note">
-          <strong>如何判断当前问题</strong>
-          <span>质量分、待补充资料、定位字段为空、同步按钮禁用,属于当前 P0a 必须继续优化的问题;局部重新生成、素材库联动、自动对标数据和更细的脚本版本管理,属于后续 P1/P2 增强。</span>
-        </div>
-
-        <div class="ip-debug-panel ip-bridge-panel" *ngIf="plan.bridgeRecords?.length">
-          <details open>
-            <summary>
-              <strong>制作/回填记录</strong>
-              <span>最近 {{ recentBridgeRecords(plan).length }} 条动作,可用于追踪生产闭环</span>
-            </summary>
-            <div class="ip-debug-grid ip-bridge-grid">
-              <section *ngFor="let record of recentBridgeRecords(plan); trackBy: trackById" [class.is-failed]="record.status === 'failed'" [class.is-skipped]="record.status === 'skipped'">
-                <strong>{{ displayText(record.title) }}</strong>
-                <span>{{ bridgeTargetLabel(record.target) }} / {{ bridgeStatusLabel(record.status) }}</span>
-                <small>{{ record.createdAt | date:'MM-dd HH:mm:ss' }}</small>
-                <small>{{ record.sourceType }} -> {{ record.targetId }}</small>
-                <p>{{ displayText(record.message) }}</p>
-              </section>
-            </div>
-          </details>
+          <section class="publish-package-modal__section publish-package-modal__monitoring">
+            <h4>发布后复盘指标</h4>
+            <p>{{ (pkg.postPublishMonitoringTargets || []).join('、') || '至少记录播放、点赞、评论、收藏、分享和人工判断。' }}</p>
+          </section>
         </div>
 
-        <div class="ip-debug-panel" *ngIf="plan.regenerationNotes?.length">
-          <details>
-            <summary>
-              <strong>局部重生成记录</strong>
-              <span>同一阶段最多允许手动重试 2 次</span>
-            </summary>
-            <div class="ip-debug-grid">
-              <section *ngFor="let note of recentRegenerationNotes(plan); trackBy: trackById" [class.is-failed]="note.status === 'failed'">
-                <strong>{{ stageLabel(note.stage) }}</strong>
-                <span>第 {{ note.attempt }} 次 / {{ note.status === 'failed' ? '失败' : '完成' }}</span>
-                <small>{{ note.createdAt | date:'MM-dd HH:mm:ss' }}</small>
-                <small *ngIf="note.preservedUserEdits">保留 {{ note.preservedUserEdits }} 处手动编辑</small>
-                <small *ngIf="note.failureCode">{{ note.failureCode }}</small>
-                <p>{{ note.summary }}</p>
-              </section>
-            </div>
-          </details>
-        </div>
+        <footer>
+          <button class="btn btn-sm" type="button" [disabled]="isPublishPackagePolishing(pkg)" (click)="polishPublishPackage(pkg)">{{ publishPackagePolishLabel(pkg) }}</button>
+          <button class="btn btn-sm" type="button" (click)="copyPublishPackageMarkdown(pkg)">复制发布包</button>
+          <button class="btn btn-sm" type="button" *ngIf="pkg.status === 'draft'" (click)="updatePublishPackageStatus(pkg.id, 'ready_for_review')">标记待审核</button>
+          <button class="btn btn-sm btn-primary" type="button" *ngIf="pkg.status === 'ready_for_review'" (click)="updatePublishPackageStatus(pkg.id, 'approved')">确认可发布</button>
+        </footer>
+      </section>
+    </div>
 
-        <div class="ip-debug-panel" *ngIf="plan.status === 'failed' || plan.errorMessage || plan.generationProgress.length">
-          <details>
-            <summary>
-              <strong>生成过程与调试信息</strong>
-              <span>{{ copyMessage() || '用于定位接口、JSON 和质量门槛问题' }}</span>
-            </summary>
-            <div class="ip-debug-actions">
-              <button class="btn btn-sm" type="button" (click)="copyPlanDebugInfo(plan)">复制调试信息</button>
-            </div>
-            <div class="ip-debug-grid">
-              <section *ngFor="let item of plan.generationProgress; trackBy: trackByStage">
-                <strong>{{ item.label }}</strong>
-                <span>{{ item.status }}</span>
-                <small *ngIf="item.durationMs">{{ item.durationMs }}ms</small>
-                <small *ngIf="item.failureCode">{{ item.failureCode }}</small>
-                <p *ngIf="item.errorMessage">{{ item.errorMessage }}</p>
-                <pre *ngIf="item.rawPreview">{{ item.rawPreview }}</pre>
-              </section>
-            </div>
-          </details>
+    <div class="publish-binding-panel" [hidden]="activeWorkspace() !== 'overview' && activeWorkspace() !== 'publish'" *ngIf="activePlan() as plan">
+      <header class="publish-binding-panel__head">
+        <div>
+          <span class="ip-kicker">发布复盘</span>
+          <h3>发布复盘:把真实表现反哺下一轮</h3>
+          <p *ngIf="plan.publishPackages?.length; else noPublishPackageYet">人工发布后绑定真实作品,补齐可得数据和判断,再把复盘结论转成下一轮可执行的候选选题。</p>
+          <ng-template #noPublishPackageYet>
+            <p>当前还没有发布包。先从内容方向选择选题、生成分镜脚本,再生成发布包。</p>
+          </ng-template>
         </div>
+        <div class="publish-retrospective-summary" *ngIf="latestPublishRetrospective() as review">
+          <span>最近复盘</span>
+          <strong>{{ retrospectiveOutcomeLabel(review.outcome) }}</strong>
+          <small>下一步:{{ review.nextAction === 'continue' ? '继续放大' : review.nextAction === 'adjust' ? '调整后再测' : review.nextAction === 'pause' ? '暂停该打法' : '更换方向' }}</small>
+        </div>
+      </header>
 
-        <nav class="ip-result-tabs" aria-label="方案内容">
-          <button type="button" [class.is-active]="resultView() === 'overview'" (click)="setResultView('overview')">总览判断</button>
-          <button type="button" [class.is-active]="resultView() === 'topics'" (click)="setResultView('topics')">选题打磨</button>
-          <button type="button" [class.is-active]="resultView() === 'scripts'" (click)="setResultView('scripts')">脚本稿</button>
-          <button type="button" [class.is-active]="resultView() === 'plan'" (click)="setResultView('plan')">测试计划</button>
-        </nav>
-
-        <ng-container *ngIf="resultView() === 'overview'">
-          <div class="ip-quality ip-quality--panel" *ngIf="plan.qualityCheck as q">
+      <div class="publish-binding-list" *ngIf="plan.publishPackages?.length">
+        <article class="publish-binding-card" *ngFor="let pkg of plan.publishPackages; trackBy: trackById">
+          <div class="publish-binding-card__head">
             <div>
-              <strong>质量检查</strong>
-              <span [class.is-pass]="q.passed">{{ q.passed ? '可进入生产链路' : '需要补充资料' }}</span>
+              <span>{{ evidencePlatformLabel(pkg.platform) }} / {{ publishStatusLabel(pkg.status) }}</span>
+              <strong>{{ pkg.titleOptions[0] || '未命名发布包' }}</strong>
+              <small>{{ publishReviewStageLabel(pkg) }}:{{ publishReviewNextStep(pkg) }}</small>
             </div>
-            <div class="ip-quality-scores">
-              <span>定位 {{ q.positioningClarityScore }}</span>
-              <span>用户 {{ q.audienceClarityScore }}</span>
-              <span>素材 {{ q.evidenceSupportScore }}</span>
-              <span>迁移 {{ q.benchmarkTransferScore }}</span>
-              <span>选题 {{ q.topicUsabilityScore }}</span>
-            </div>
-            <div class="ip-warning-list" *ngIf="q.blockingIssues.length || q.improvementSuggestions.length">
-              <p *ngFor="let issue of q.blockingIssues">阻断:{{ displayText(issue) }}</p>
-              <p *ngFor="let suggestion of q.improvementSuggestions">建议:{{ displayText(suggestion) }}</p>
+            <div class="ip-actions">
+              <button class="btn btn-sm" type="button" (click)="openPublishPackageModal(pkg)">查看发布包</button>
+              <button class="btn btn-sm" type="button" (click)="autoMatchPublishPackage(pkg)">自动匹配作品</button>
             </div>
           </div>
 
-          <article class="ip-card ip-section-card">
-            <header>
-              <div>
-                <span class="ip-kicker">定位诊断</span>
-                <h3>先判断这个 IP 是否成立</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'diagnosis') }}</small>
-              </div>
-              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('diagnosis')">
-                {{ regeneratingStage() === 'diagnosis' ? '重生成中...' : '重生成定位' }}
-              </button>
-            </header>
-            <ng-container *ngIf="plan.diagnosis; else diagnosisPending">
-              <div class="ip-diagnosis-grid">
-                <p><strong>目标用户</strong>{{ displayText(plan.diagnosis.targetAudience.summary) }}</p>
-                <p><strong>市场机会</strong>{{ displayText(plan.diagnosis.marketOpportunity) }}</p>
-                <p><strong>差异化</strong>{{ displayText(plan.diagnosis.differentiation) }}</p>
-                <p><strong>变现路径</strong>{{ displayText(plan.diagnosis.monetizationPath) }}</p>
-              </div>
-              <div class="ip-chip-row">
-                <span *ngFor="let tag of plan.diagnosis.personaTags || []">{{ displayText(tag) }}</span>
-              </div>
-            </ng-container>
-            <ng-template #diagnosisPending>
-              <p class="ip-empty">定位诊断尚未生成完成,请等待生成流程结束或查看错误信息。</p>
-            </ng-template>
-          </article>
+          <div class="publish-binding-form">
+            <input
+              type="text"
+              [ngModel]="publishBindingUrlDrafts()[pkg.id] || ''"
+              (ngModelChange)="updatePublishBindingUrlDraft(pkg.id, $event)"
+              placeholder="粘贴已发布作品链接,用于手动绑定">
+            <button class="btn btn-sm btn-primary" type="button" (click)="manualBindPublishPackage(pkg)">手动绑定</button>
+          </div>
 
-          <article class="ip-card ip-section-card">
-            <header>
-              <div>
-                <span class="ip-kicker">对标迁移</span>
-                <h3>看哪些能学,哪些不能照搬</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'benchmark') }}</small>
+          <ng-container *ngIf="bindingForPackage(pkg.id) as binding; else noPublishBinding">
+            <div
+              class="publish-binding-status"
+              [ngClass]="bindingDataStatusClass(pkg, binding)">
+              <div class="publish-binding-status__body">
+                <span>{{ bindingDataStatusLabel(pkg, binding) }}</span>
+                <strong>{{ binding.awemeId || binding.workUrl || '已绑定作品' }}</strong>
+                <small>{{ bindingDataStatusDescription(pkg, binding) }}</small>
               </div>
-              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('benchmark')">
-                {{ regeneratingStage() === 'benchmark' ? '重生成中...' : '重生成对标' }}
-              </button>
-            </header>
-            <p class="ip-empty" *ngIf="!plan.benchmarkAnalyses.length">对标拆解尚未生成完成。</p>
-            <div class="ip-analysis-grid">
-              <section *ngFor="let item of plan.benchmarkAnalyses; trackBy: trackByIndex">
-                <h4>{{ displayText(item.accountName) }} <small>{{ item.fitScore }}分</small></h4>
-                <p>{{ displayText(item.positioning) }}</p>
-                <p><strong>可借鉴:</strong>{{ displayText(item.borrowablePoints) }}</p>
-                <p><strong>不可照搬:</strong>{{ displayText(item.nonCopyablePoints) }}</p>
-                <p><strong>迁移方向:</strong>{{ displayText(item.migrationDirection) }}</p>
-              </section>
+              <button class="btn btn-sm" type="button" (click)="rejectPublishBinding(binding)">取消绑定</button>
             </div>
-          </article>
 
-          <article class="ip-card ip-section-card">
-            <header>
-              <div>
-                <span class="ip-kicker">机会地图</span>
-                <h3>从爆款点转成自己的内容方向</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'opportunities') }}</small>
-              </div>
-              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('opportunities')">
-                {{ regeneratingStage() === 'opportunities' ? '重生成中...' : '重生成机会地图' }}
+            <div class="publication-review-form">
+              <label>
+                <span>真实链接</span>
+                <input type="text" [ngModel]="reviewDraft(pkg.id).url" (ngModelChange)="updateReviewDraft(pkg.id, { url: $event })" placeholder="可选,留空则使用绑定链接">
+              </label>
+              <label>
+                <span>发布时间</span>
+                <input type="datetime-local" [ngModel]="reviewDraft(pkg.id).publishedAt" (ngModelChange)="updateReviewDraft(pkg.id, { publishedAt: $event })">
+              </label>
+              <label><span>播放</span><input type="number" min="0" [ngModel]="reviewDraft(pkg.id).views" (ngModelChange)="updateReviewDraft(pkg.id, { views: $event })"></label>
+              <label><span>点赞</span><input type="number" min="0" [ngModel]="reviewDraft(pkg.id).likes" (ngModelChange)="updateReviewDraft(pkg.id, { likes: $event })"></label>
+              <label><span>评论</span><input type="number" min="0" [ngModel]="reviewDraft(pkg.id).comments" (ngModelChange)="updateReviewDraft(pkg.id, { comments: $event })"></label>
+              <label><span>收藏</span><input type="number" min="0" [ngModel]="reviewDraft(pkg.id).favorites" (ngModelChange)="updateReviewDraft(pkg.id, { favorites: $event })"></label>
+              <label><span>分享</span><input type="number" min="0" [ngModel]="reviewDraft(pkg.id).shares" (ngModelChange)="updateReviewDraft(pkg.id, { shares: $event })"></label>
+              <label><span>人工评分</span><input type="number" min="0" max="5" [ngModel]="reviewDraft(pkg.id).rating" (ngModelChange)="updateReviewDraft(pkg.id, { rating: $event })"></label>
+              <label class="publication-review-form__notes">
+                <span>复盘判断</span>
+                <textarea [ngModel]="reviewDraft(pkg.id).notes" (ngModelChange)="updateReviewDraft(pkg.id, { notes: $event })" placeholder="这条作品是否符合预期?哪些定位、选题或表达需要调整?"></textarea>
+              </label>
+              <button class="btn btn-sm btn-primary" type="button" [disabled]="publishRetrospectiveStates()[pkg.id] === 'analyzing'" (click)="recordPublicationReview(pkg.id)">
+                {{ publishRetrospectiveStates()[pkg.id] === 'analyzing' ? '分析复盘中...' : '记录复盘' }}
               </button>
-            </header>
-            <p class="ip-empty" *ngIf="!plan.opportunityMap.length">机会地图尚未生成完成。</p>
-            <div class="ip-opportunity-list">
-              <section *ngFor="let item of plan.opportunityMap; trackBy: trackById">
-                <div>
-                  <strong>{{ displayText(item.type) }}</strong>
-                  <span>{{ item.fitScore }}分</span>
-                </div>
-                <p>{{ displayText(item.reason) }}</p>
-                <small>风险边界:{{ displayText(item.riskBoundary) }}</small>
-              </section>
             </div>
-          </article>
-        </ng-container>
-
-        <ng-container *ngIf="resultView() === 'topics'">
-          <article class="ip-card ip-section-card">
-            <header>
+            <div class="publish-review-result" *ngIf="retrospectiveForPackage(pkg.id) as review">
               <div>
-                <span class="ip-kicker">P0b 可编辑</span>
-                <h3>优先打磨高优先级选题</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'topics_scripts_plan') }}</small>
+                <span>复盘报告已生成</span>
+                <strong>{{ review.reportTitle || retrospectiveOutcomeLabel(review.outcome) }} / 下一步:{{ retrospectiveNextActionLabel(review.nextAction) }}</strong>
+                <p>{{ review.insight?.nextRoundBrief || review.diagnosis }}</p>
               </div>
               <div class="ip-actions">
-                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
-                  {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成选题脚本' }}
-                </button>
-                <button class="btn btn-primary" type="button" [disabled]="plan.status !== 'ready' || !productionReadyTopics().length" (click)="syncHighPriorityTopics()">同步可制作</button>
+                <button class="btn btn-sm" type="button" (click)="openPublishRetrospectiveModal(pkg, review)">查看复盘报告</button>
+                <button class="btn btn-sm" type="button" (click)="setWorkspace('directions')">查看方向选题</button>
               </div>
-            </header>
-            <p class="ip-empty" *ngIf="!plan.topics.length">选题尚未生成完成。</p>
-            <div class="ip-topic-cards">
-              <section *ngFor="let topic of sortedTopics(); trackBy: trackById" class="ip-topic-card" [class.is-high]="topic.priority === 'high'">
-                <div class="ip-topic-card__head">
-                  <span>{{ priorityLabel(topic.priority) }}优先级</span>
-                  <small>{{ trafficLayerLabel(topic.trafficLayer) }} / {{ topic.column }}</small>
-                </div>
-                <div class="ip-topic-quality" [ngClass]="topicQualityClass(topic.qualityLevel)">
-                  <strong>{{ topicQualityLabel(topic.qualityLevel) }}</strong>
-                  <span>{{ topic.productionReadinessScore || 0 }}/100</span>
-                  <small *ngIf="topic.polishSuggestions?.length">{{ displayText(topic.polishSuggestions) }}</small>
-                </div>
-                <label>标题<input [(ngModel)]="topic.title" (change)="saveTopicField(topic, {title: topic.title})"></label>
-                <label>适配理由<textarea rows="3" [(ngModel)]="topic.fitReason" (change)="saveTopicField(topic, {fitReason: topic.fitReason})"></textarea></label>
-                <p><strong>内容目标:</strong>{{ displayText(topic.contentGoal) }}</p>
-                <p><strong>所需素材:</strong>{{ displayText(topic.requiredMaterials) }}</p>
-                <p><strong>风险:</strong>{{ displayText(topic.riskNote) }}</p>
-                <div class="ip-topic-card__actions">
-                  <span *ngIf="topic.userEdited">已手动编辑</span>
-                  <button class="btn btn-sm" type="button" [disabled]="plan.status !== 'ready' || topic.qualityLevel !== 'make_now' || !!topic.syncedTopicId" (click)="syncTopic(topic)">
-                    {{ topic.syncedTopicId ? '已同步选题池' : '同步选题池' }}
-                  </button>
-                  <button class="btn btn-sm btn-primary" type="button" [disabled]="plan.status !== 'ready' || topic.qualityLevel !== 'make_now'" (click)="createTopicVideoDraft(topic)">
-                    生成主题视频草稿
-                  </button>
+              <section class="publish-next-round" *ngIf="followUpTopicsForReview(review) as followUps">
+                <header>
+                  <div>
+                    <span>下一轮候选选题</span>
+                    <strong>{{ followUps.length ? '选择要进入下一轮测试的题' : '本次复盘没有生成候选选题' }}</strong>
+                  </div>
+                  <small>{{ followUps.length ? '来源可追踪,纳入后进入方向选题。' : '可以查看报告后手动调整方向和选题。' }}</small>
+                </header>
+                <div class="publish-next-round__list" *ngIf="followUps.length; else noFollowUpTopics">
+                  <article
+                    class="publish-followup-topic"
+                    [ngClass]="followUpTopicStatusClass(topic)"
+                    *ngFor="let topic of followUps; trackBy: trackById">
+                    <div>
+                      <span>{{ followUpTopicStatusLabel(topic) }}</span>
+                      <strong>{{ topic.title }}</strong>
+                      <p>{{ topic.reason }}</p>
+                      <small>来源:{{ followUpTopicSourceLabel(topic, pkg, review) }}</small>
+                    </div>
+                    <ul *ngIf="topic.sourceSuggestions.length">
+                      <li *ngFor="let signal of topic.sourceSuggestions.slice(0, 3)">{{ signal }}</li>
+                    </ul>
+                    <div class="ip-actions">
+                      <button
+                        class="btn btn-sm btn-primary"
+                        type="button"
+                        *ngIf="!topic.planTopicId && !topic.syncedTopicId && topic.status !== 'accepted' && topic.status !== 'dismissed'"
+                        (click)="acceptFollowUpTopic(topic.id)">
+                        纳入方向选题
+                      </button>
+                      <button
+                        class="btn btn-sm"
+                        type="button"
+                        *ngIf="!topic.planTopicId && !topic.syncedTopicId && topic.status !== 'accepted' && topic.status !== 'dismissed'"
+                        (click)="dismissFollowUpTopic(topic.id)">
+                        暂不处理
+                      </button>
+                      <button
+                        class="btn btn-sm"
+                        type="button"
+                        *ngIf="topic.planTopicId || topic.syncedTopicId || topic.status === 'accepted'"
+                        (click)="goToDirections()">
+                        去方向选题
+                      </button>
+                    </div>
+                  </article>
                 </div>
+                <ng-template #noFollowUpTopics>
+                  <p class="publish-next-round__empty">LLM 本次没有给出可直接测试的下一轮选题。建议先查看复盘报告,必要时回到方向选题手动补充。</p>
+                </ng-template>
               </section>
             </div>
-          </article>
-        </ng-container>
+          </ng-container>
 
-        <ng-container *ngIf="resultView() === 'scripts'">
-          <article class="ip-card ip-section-card">
-            <header>
-              <div>
-                <span class="ip-kicker">脚本稿</span>
-                <h3>先看完整口播,再看大纲</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'topics_scripts_plan') }}</small>
-              </div>
-              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
-                {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成脚本' }}
-              </button>
-            </header>
-            <p class="ip-empty" *ngIf="!plan.scripts.length">脚本尚未生成完成。</p>
-
-            <section class="ip-script-group" *ngIf="fullScripts().length">
-              <h4>完整口播(优先测试)</h4>
-              <p class="ip-script-help">默认先看钩子、观点和可拍摄性;完整口播折叠查看,避免长文本干扰判断。</p>
-              <details class="ip-script-detail" *ngFor="let script of fullScripts(); let i = index; trackBy: trackById" [open]="i === 0">
-                <summary>
-                  <strong>{{ script.title || script.id }}</strong>
-                  <span>{{ scriptQualityLabel(script.qualityLevel) }} / {{ script.userEdited ? '已手动编辑' : 'AI 草稿' }}</span>
-                </summary>
-                <div class="ip-script-quality" [ngClass]="scriptQualityClass(script.qualityLevel)">
-                  <strong>{{ displayText(script.scriptSummary) }}</strong>
-                  <small *ngIf="script.productionNotes?.length">{{ displayText(script.productionNotes) }}</small>
-                </div>
-                <div class="ip-script-priority">
-                  <section>
-                    <span>开头钩子</span>
-                    <strong>{{ displayText(script.hook) }}</strong>
-                  </section>
-                  <section>
-                    <span>核心观点</span>
-                    <strong>{{ displayText(script.viewpoint) }}</strong>
-                  </section>
-                  <section>
-                    <span>风险提示</span>
-                    <strong>{{ displayText(script.riskNote) }}</strong>
-                  </section>
-                </div>
-                <div class="ip-script-edit">
-                  <label>开头钩子<input [(ngModel)]="script.hook" (change)="saveScriptField(script, {hook: script.hook})"></label>
-                  <label>观点<textarea rows="2" [(ngModel)]="script.viewpoint" (change)="saveScriptField(script, {viewpoint: script.viewpoint})"></textarea></label>
-                  <details class="ip-script-fulltext">
-                    <summary>展开完整口播</summary>
-                  <label>完整口播<textarea rows="6" [(ngModel)]="script.fullScript" (change)="saveScriptField(script, {fullScript: script.fullScript})"></textarea></label>
-                  </details>
-                  <p><strong>拍摄建议:</strong>{{ displayText(script.shootingTips) }}</p>
-                  <p><strong>可替换素材位:</strong>{{ displayText(script.replaceableSlots) }}</p>
-                  <div class="ip-actions">
-                    <button class="btn btn-sm btn-primary" type="button" [disabled]="plan.status !== 'ready' || script.qualityLevel !== 'ready_to_record'" (click)="createDigitalHumanDraft(script)">
-                      生成数字人口播草稿
-                    </button>
-                  </div>
-                </div>
-              </details>
-            </section>
+          <ng-template #noPublishBinding>
+            <p class="publish-binding-card__empty">尚未绑定真实作品。人工发布后刷新账号自动匹配,或直接粘贴作品链接。</p>
+          </ng-template>
+        </article>
+      </div>
+    </div>
+  </section>
 
-            <section class="ip-script-group" *ngIf="outlineScripts().length">
-              <h4>脚本大纲(后续打磨)</h4>
-              <details class="ip-script-detail" *ngFor="let script of outlineScripts(); trackBy: trackById">
-                <summary>
-                  <strong>{{ script.title || script.id }}</strong>
-                  <span>{{ scriptQualityLabel(script.qualityLevel) }}</span>
-                </summary>
-                <div class="ip-script-quality" [ngClass]="scriptQualityClass(script.qualityLevel)">
-                  <strong>{{ displayText(script.scriptSummary) }}</strong>
-                  <small *ngIf="script.productionNotes?.length">{{ displayText(script.productionNotes) }}</small>
-                </div>
-                <div class="ip-script-outline">
-                  <p><strong>钩子:</strong>{{ displayText(script.hook) }}</p>
-                  <p><strong>痛点:</strong>{{ displayText(script.painPoint) }}</p>
-                  <p><strong>观点:</strong>{{ displayText(script.viewpoint) }}</p>
-                  <p><strong>案例/方法:</strong>{{ displayText(script.caseOrMethod) }}</p>
-                  <p><strong>结尾:</strong>{{ displayText(script.closingCta) }}</p>
-                </div>
-              </details>
-            </section>
+  <div class="publish-retrospective-modal-backdrop" *ngIf="activePublishRetrospectiveModal() as modal" (click)="closePublishRetrospectiveModal()">
+    <section class="publish-retrospective-modal" role="dialog" aria-modal="true" aria-label="发布复盘报告" (click)="$event.stopPropagation()">
+      <header>
+        <div>
+          <span class="ip-kicker">发布复盘报告</span>
+          <h2>{{ modal.record.reportTitle || modal.pkg.titleOptions[0] || '发布复盘报告' }}</h2>
+          <p>{{ retrospectiveOutcomeLabel(modal.record.outcome) }} / 下一步:{{ retrospectiveNextActionLabel(modal.record.nextAction) }} / {{ modal.record.updatedAt | date:'yyyy-MM-dd HH:mm' }}</p>
+        </div>
+        <button class="ip-work-report-modal__close" type="button" aria-label="关闭发布复盘报告" title="关闭" (click)="closePublishRetrospectiveModal()">×</button>
+      </header>
+
+      <div class="publish-retrospective-modal__body">
+        <section class="publish-retrospective-modal__summary">
+          <div class="publish-retrospective-modal__summary-card">
+            <span>绑定作品</span>
+            <strong>{{ modal.work?.title || modal.work?.desc || modal.record.awemeId || modal.record.workId || '未识别作品标题' }}</strong>
+          </div>
+          <div class="publish-retrospective-modal__summary-card">
+            <span>结果判断</span>
+            <strong>{{ modal.record.diagnosis }}</strong>
+          </div>
+        </section>
+
+        <section>
+          <h3>下一轮判断</h3>
+          <p>{{ modal.record.insight?.nextRoundBrief || modal.record.diagnosis }}</p>
+        </section>
+
+        <section *ngIf="modal.record.insight?.causeSignals?.length">
+          <h3>表现信号</h3>
+          <article class="publish-retrospective-modal__item" *ngFor="let signal of modal.record.insight?.causeSignals">
+            <strong>{{ signal.label }} · {{ signal.score }}</strong>
+            <p>{{ signal.judgment }}</p>
+            <small *ngIf="signal.evidence.length">{{ signal.evidence.join(' / ') }}</small>
           </article>
-        </ng-container>
+        </section>
 
-        <ng-container *ngIf="resultView() === 'plan'">
-          <article class="ip-card ip-section-card">
-            <header>
-              <div>
-                <span class="ip-kicker">内容测试</span>
-                <h3>7 天详细测试 + 30 天方向</h3>
-                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'quality_check') }}</small>
-              </div>
-              <div class="ip-actions">
-                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
-                  {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成测试计划' }}
-                </button>
-                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('quality_check')">
-                  {{ regeneratingStage() === 'quality_check' ? '检查中...' : '重新质量检查' }}
-                </button>
-              </div>
-            </header>
-            <p class="ip-empty" *ngIf="!plan.sevenDayTestPlan.length">7 天测试计划尚未生成完成。</p>
-            <div class="ip-plan-days">
-              <section *ngFor="let day of plan.sevenDayTestPlan; trackBy: trackByIndex">
-                <strong>第 {{ day.day }} 天:{{ displayText(day.topicTitle) }}</strong>
-                <p>{{ displayText(day.testGoal) }}</p>
-                <p><strong>拍摄重点:</strong>{{ displayText(day.shootingFocus) }}</p>
-                <p><strong>观察:</strong>{{ displayText(day.observeMetrics) }}</p>
-                <p><strong>复盘问题:</strong>{{ displayText(day.reviewQuestions) }}</p>
-              </section>
-            </div>
+        <section *ngIf="modal.record.insight?.directionAdjustments?.length">
+          <h3>方向调整</h3>
+          <article class="publish-retrospective-modal__item" *ngFor="let item of modal.record.insight?.directionAdjustments">
+            <strong>{{ item.directionTitle || '未命名方向' }}</strong>
+            <p>{{ item.reason }}</p>
+            <small>建议动作:{{ retrospectiveNextActionLabel(item.action === 'increase' || item.action === 'keep' ? 'continue' : item.action === 'pause' ? 'pause' : 'adjust') }}</small>
           </article>
+        </section>
 
-          <article class="ip-card ip-section-card" *ngIf="plan.thirtyDayDirection as direction">
-            <h3>30 天栏目测试方向</h3>
-            <div class="ip-chip-row">
-              <span *ngFor="let column of direction.columnStructure">{{ displayText(column.column) }} {{ column.ratio }}%</span>
-            </div>
-            <p><strong>每周重点:</strong>{{ displayText(direction.weeklyFocus) }}</p>
-            <p><strong>素材方向:</strong>{{ displayText(direction.materialDirections) }}</p>
-            <p><strong>迭代路径:</strong>{{ displayText(direction.iterationPath) }}</p>
+        <section *ngIf="modal.record.insight?.nextTopicSignals?.length">
+          <h3>下一轮选题信号</h3>
+          <article class="publish-retrospective-modal__item" *ngFor="let signal of modal.record.insight?.nextTopicSignals">
+            <strong>{{ signal.title }}</strong>
+            <p>{{ signal.userProblem }} · {{ signal.reason }}</p>
+            <small>{{ signal.targetAudience }}</small>
           </article>
+        </section>
 
-          <article class="ip-card ip-section-card" *ngIf="plan.missingInputs.length">
-            <h3>待补充资料</h3>
-            <div class="ip-missing-list">
-              <p *ngFor="let item of plan.missingInputs">{{ displayText(item.type) }}:{{ displayText(item.description) }}</p>
-            </div>
+        <section *ngIf="modal.record.insight?.scriptConstraints?.length">
+          <h3>脚本约束</h3>
+          <ul>
+            <li *ngFor="let item of modal.record.insight?.scriptConstraints">{{ item.instruction }}<span *ngIf="item.avoid">;避免:{{ item.avoid }}</span></li>
+          </ul>
+        </section>
+
+        <section>
+          <h3>真实表现</h3>
+          <ul>
+            <li *ngFor="let signal of modal.record.actualSignals">{{ signal }}</li>
+          </ul>
+        </section>
+      </div>
+
+      <footer>
+        <button class="btn btn-sm" type="button" (click)="closePublishRetrospectiveModal()">关闭</button>
+        <button class="btn btn-sm btn-primary" type="button" (click)="setWorkspace('directions'); closePublishRetrospectiveModal()">查看下一轮选题</button>
+      </footer>
+    </section>
+  </div>
+
+  <div class="ip-work-report-modal-backdrop" *ngIf="activeWorkReportModal() as modal" (click)="closeWorkReportModal()">
+    <section class="ip-work-report-modal" role="dialog" aria-modal="true" aria-labelledby="ip-work-report-title" (click)="$event.stopPropagation()">
+      <header>
+        <div>
+          <span class="ip-kicker">作品分析报告</span>
+          <h2 id="ip-work-report-title">{{ modal.report.title || modal.work.title || modal.work.desc || '作品分析报告' }}</h2>
+          <p>{{ workReportSourceLabel(modal.report) }} / 已保存 / {{ modal.report.createdAt | date:'yyyy-MM-dd HH:mm' }}</p>
+        </div>
+        <button class="ip-work-report-modal__close" type="button" aria-label="关闭作品分析报告" title="关闭" (click)="closeWorkReportModal()">×</button>
+      </header>
+
+      <div class="ip-work-report-modal__body">
+        <section class="ip-work-report-modal__sections" *ngIf="modal.sections.length; else emptyWorkReport">
+          <article class="ip-work-report-modal__section" *ngFor="let section of modal.sections">
+            <span>{{ section.tone === 'structure' ? '内容结构' : '运营迁移' }} / 置信度 {{ confidenceLabel(section.confidence) }}</span>
+            <h3>{{ section.title }}</h3>
+            <p>{{ section.body }}</p>
+            <p *ngIf="section.judgment"><strong>运营判断:</strong>{{ section.judgment }}</p>
+            <ul *ngIf="section.signals.length">
+              <li *ngFor="let signal of section.signals">{{ signal }}</li>
+            </ul>
+            <small *ngIf="section.gaps.length">待补:{{ section.gaps.join('、') }}</small>
           </article>
-        </ng-container>
-      </section>
-    </main>
+        </section>
+        <ng-template #emptyWorkReport>
+          <p class="ip-work-report-modal__empty">当前作品已有运行记录,但还没有可阅读的报告内容。</p>
+        </ng-template>
+
+        <details class="ip-work-report-modal__comments">
+          <summary>查看对应评论样本({{ modal.comments.length }} 条)</summary>
+          <ol *ngIf="modal.comments.length; else emptyWorkReportComments">
+            <li *ngFor="let comment of modal.comments">
+              <p>{{ comment.text }}</p>
+              <small>{{ workReportCommentMeta(comment) }}</small>
+            </li>
+          </ol>
+        </details>
+        <ng-template #emptyWorkReportComments>
+          <p class="ip-work-report-modal__empty">当前作品报告没有可追溯的评论样本。</p>
+        </ng-template>
+
+        <details *ngIf="modal.transcriptText">
+          <summary>查看视频文字稿</summary>
+          <p>{{ modal.transcriptText }}</p>
+        </details>
+      </div>
+    </section>
   </div>
+
+
+  <div class="evidence-drawer-backdrop" *ngIf="evidenceDrawerIds().length" (click)="closeEvidenceDrawer()"></div>
+  <section class="evidence-drawer" *ngIf="evidenceDrawerIds().length" role="dialog" aria-modal="true" aria-label="证据来源">
+    <header>
+      <div>
+        <span class="ip-kicker">可追溯依据</span>
+        <h3>{{ evidenceDrawerTitle() }}</h3>
+        <p>{{ selectedEvidenceSources().length }} 条依据,原始长文本默认收起。</p>
+      </div>
+      <button class="evidence-drawer__close" type="button" aria-label="关闭证据弹窗" title="关闭" (click)="closeEvidenceDrawer()">×</button>
+    </header>
+    <div class="evidence-drawer__body">
+      <p class="ip-empty" *ngIf="!selectedEvidenceSources().length">当前引用未匹配到本地证据,请刷新账号或重新分析。</p>
+      <section class="evidence-drawer__group" *ngFor="let group of selectedEvidenceGroups(); trackBy: trackById">
+        <div class="evidence-drawer__group-heading">
+          <div><h4>{{ group.label }}</h4><p>{{ group.description }}</p></div>
+          <span>{{ group.items.length }} 条</span>
+        </div>
+        <article class="evidence-drawer__item" *ngFor="let evidence of group.items; trackBy: trackById">
+          <div class="evidence-drawer__meta">
+            <span>{{ evidence.typeLabel }}</span>
+            <small *ngIf="evidence.meta">{{ evidence.meta }}</small>
+          </div>
+          <h4>{{ evidence.title }}</h4>
+          <p>{{ evidencePreview(evidence) }}</p>
+          <details *ngIf="evidenceHasDetails(evidence)">
+            <summary>查看完整材料</summary>
+            <p>{{ evidence.summary }}</p>
+          </details>
+          <small class="evidence-gap-note" *ngIf="evidence.gaps?.length">仍缺:{{ evidence.gaps?.join('、') }}</small>
+          <a *ngIf="evidence.url" [href]="evidence.url" target="_blank" rel="noopener noreferrer">打开原始来源</a>
+        </article>
+      </section>
+    </div>
+  </section>
 </section>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 2083 - 19
src/app/pages/ip-operator/ip-operator.component.spec.ts


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 4433 - 101
src/app/pages/ip-operator/ip-operator.component.ts


+ 158 - 0
src/app/services/ip-account-diagnosis.service.spec.ts

@@ -0,0 +1,158 @@
+import { IpAccountDiagnosisService } from './ip-account-diagnosis.service';
+import { IpMonitoredAccount } from '../models/ip-operator.model';
+
+describe('IpAccountDiagnosisService', () => {
+  let service: IpAccountDiagnosisService;
+
+  beforeEach(() => {
+    service = new IpAccountDiagnosisService();
+  });
+
+  it('uses startup hypothesis mode when work sample is small', () => {
+    const snapshot = service.createSnapshotForTest(2, 0);
+    const result = service.buildLocalDiagnosis(snapshot, []);
+
+    expect(result.mode).toBe('startup_hypothesis');
+    expect(result.proposal.status).toBe('proposed');
+    expect(result.tasks.some((task) => task.type === 'positioning')).toBe(true);
+  });
+
+  it('creates three role-based content directions for account diagnosis', () => {
+    const snapshot = service.createSnapshotForTest(10, 20);
+    const result = service.buildLocalDiagnosis(snapshot, []);
+
+    expect(result.mode).toBe('data_diagnosis');
+    expect(result.directions.map((item) => item.role)).toEqual(['growth', 'trust', 'interaction']);
+    expect(result.topics.length).toBe(12);
+    expect(result.topics.every((topic) => !!topic.directionId)).toBe(true);
+    expect(result.directions.every((direction) => direction.topicIds.length === 4)).toBe(true);
+    expect(result.tasks.find((task) => task.column === 'today')?.growthImpactScore).toBeGreaterThan(80);
+  });
+
+  it('generates a diagnosis report with sample counts', () => {
+    const snapshot = service.createSnapshotForTest(8, 12);
+    const result = service.buildLocalDiagnosis(snapshot, []);
+
+    expect(result.reportMarkdown).toContain('账号诊断报告');
+    expect(result.reportMarkdown).toContain('作品样本:8 条');
+    expect(result.reportMarkdown).toContain('重点评论样本:36 条');
+    expect(result.reportMarkdown).toContain('五维诊断评分');
+    expect(result.scores.positioningClarity).toBeGreaterThan(0);
+    expect(result.scores.contentStructure).toBeGreaterThan(0);
+    expect(result.reportMarkdown).toContain('当前账号像什么 IP');
+    expect(result.reportMarkdown).toContain('当前内容实际吸引的人群');
+    expect(result.reportMarkdown).toContain('高互动作品共性');
+    expect(result.reportMarkdown).toContain('评论区用户关心的问题');
+    expect(result.reportMarkdown).toContain('当前最大增长障碍');
+    expect(result.reportMarkdown).toContain('未来 7 天优先动作');
+  });
+
+  it('uses intended track, persona, and audience for startup hypotheses', () => {
+    const snapshot = service.createSnapshotForTest(2, 0);
+    const account: IpMonitoredAccount = {
+      id: 'account_test',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: '测试账号',
+      intendedTrack: '传统行业老板 IP',
+      intendedPersona: '懂业务的增长顾问',
+      intendedAudience: '传统行业中小企业老板',
+      enabled: true,
+      lastRefreshStatus: 'completed',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    };
+
+    const result = service.buildLocalDiagnosis(snapshot, [], account);
+
+    expect(result.proposal.proposedVersion.persona).toBe('懂业务的增长顾问');
+    expect(result.proposal.proposedVersion.targetAudience).toBe('传统行业中小企业老板');
+    expect(result.reportMarkdown).toContain('传统行业老板 IP / 懂业务的增长顾问 / 传统行业中小企业老板');
+    expect(result.directions.some((item) => item.commentPainRefs.length > 0)).toBe(false);
+  });
+
+  it('summarizes account evidence instead of copying captions, bios, or comments into conclusions', () => {
+    const base = service.createSnapshotForTest(10, 0);
+    const longCaption = '新手小白安装这个 Skills 就是值得做,只看这 4 个指标。联系邮箱 demo@example.com,这是一整段很长的视频文字稿,不应该成为用户关注理由。';
+    const rawComment = '安装了做PPT的skill,但是要调用image2,需要openai的API,无法使用烦死了';
+    const snapshot = {
+      ...base,
+      profile: {
+        ...base.profile,
+        nickname: 'FredTalk',
+        signature: '深圳某部门总监,关注教小白如何从0到1学习Vibe Coding,联系邮箱 demo@example.com',
+      },
+      works: base.works.map((work, index) => ({
+        ...work,
+        title: index === 9 ? longCaption : `Vibe Coding 新手实操第 ${index + 1} 期`,
+        desc: index === 9 ? longCaption : `Vibe Coding 新手实操第 ${index + 1} 期`,
+        comments: index === 9
+          ? [{ id: 'comment-real-1', workId: work.id, text: rawComment, capturedAt: work.capturedAt }]
+          : [],
+      })),
+    };
+
+    const result = service.buildLocalDiagnosis(snapshot, []);
+
+    expect(result.proposal.proposedVersion.persona).toContain('Vibe Coding');
+    expect(result.proposal.proposedVersion.persona).not.toBe('FredTalk');
+    expect(result.proposal.proposedVersion.followReason).toContain('可执行教程');
+    expect(result.proposal.proposedVersion.followReason).not.toContain('demo@example.com');
+    expect(result.proposal.proposedVersion.followReason).not.toContain(longCaption);
+    expect(result.proposal.proposedVersion.followReason.length).toBeLessThan(80);
+    expect(result.directions.map((item) => item.title)).toEqual([
+      'Vibe Coding新手入门与认知纠偏',
+      'Vibe Coding工具实测与完整教程',
+      'Vibe Coding真实项目复盘与问题答疑',
+    ]);
+    expect(result.directions.some((item) => ['涨粉破圈方向', '信任建立方向', '互动共鸣方向'].includes(item.title))).toBe(false);
+    expect(result.topics.some((topic) => topic.title === rawComment)).toBe(false);
+    expect(result.topics.some((topic) => topic.userProblem?.includes('安装、配置'))).toBe(true);
+  });
+
+  it('creates review tasks and a non-effective positioning proposal after refreshing bound works', () => {
+    const previous = service.createSnapshotForTest(8, 4);
+    const current = {
+      ...previous,
+      id: 'snapshot_current',
+      works: previous.works.map((work, index) => index === 0
+        ? {
+            ...work,
+            metrics: { ...work.metrics, playCount: 15000, commentCount: 60 },
+          }
+        : work),
+    };
+    const result = service.buildRefreshRetrospective(previous, current, [{
+      id: 'binding-1',
+      publishPackageId: 'publish-1',
+      accountId: current.accountId,
+      awemeId: current.works[0].awemeId,
+      status: 'auto_matched',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    }], {
+      id: 'version-1',
+      accountId: current.accountId,
+      version: 1,
+      status: 'active',
+      targetAudience: '传统行业老板',
+      persona: '增长顾问',
+      followReason: '用真实案例解决内容问题',
+      contentPillars: ['涨粉', '信任', '互动'],
+      expressionStyle: ['直接'],
+      boundaries: ['不夸大'],
+      suitableViralPatterns: ['反常识开头'],
+      evidenceItemIds: [],
+      assumptionsToValidate: [],
+      observeMetrics: ['播放量', '评论数'],
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(result.tasks[0].type).toBe('review');
+    expect(result.tasks[0].reason).toContain('当前播放 15000');
+    expect(result.positioningProposal?.status).toBe('proposed');
+    expect(result.positioningProposal?.baseVersionId).toBe('version-1');
+  });
+});

+ 604 - 0
src/app/services/ip-account-diagnosis.service.ts

@@ -0,0 +1,604 @@
+import { Injectable } from '@angular/core';
+import {
+  IpAccountDiagnosisScores,
+  IpAccountSnapshot,
+  IpContentDirection,
+  IpEvidenceItem,
+  IpMonitoredAccount,
+  IpOperationTask,
+  IpPositioningProposal,
+  IpPositioningVersion,
+  IpPublishWorkBinding,
+  IpTopic,
+} from '../models/ip-operator.model';
+
+export interface LocalAccountDiagnosisResult {
+  mode: 'data_diagnosis' | 'startup_hypothesis';
+  reportMarkdown: string;
+  scores: IpAccountDiagnosisScores;
+  proposal: IpPositioningProposal;
+  directions: IpContentDirection[];
+  topics: IpTopic[];
+  tasks: IpOperationTask[];
+}
+
+export interface AccountRefreshRetrospectiveResult {
+  summaryMarkdown: string;
+  tasks: IpOperationTask[];
+  positioningProposal?: IpPositioningProposal;
+}
+
+interface AccountContentContext {
+  domain: string;
+  targetAudience: string;
+  persona: string;
+  followReason: string;
+  pillars: [string, string, string];
+  painPoints: string[];
+  workCount: number;
+  commentCount: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountDiagnosisService {
+  buildLocalDiagnosis(snapshot: IpAccountSnapshot, evidence: IpEvidenceItem[], account?: IpMonitoredAccount): LocalAccountDiagnosisResult {
+    const now = new Date().toISOString();
+    const mode = snapshot.dataMode;
+    const accountId = snapshot.accountId;
+    const topWorks = [...snapshot.works].sort((a, b) => b.interactionScore - a.interactionScore).slice(0, 3);
+    const evidenceIds = [
+      ...snapshot.evidenceItemIds,
+      ...evidence.map((item) => item.id),
+    ].filter((id, index, list) => !!id && list.indexOf(id) === index).slice(0, 12);
+
+    const commentRefs = snapshot.works.flatMap((work) => work.comments.map((comment) => comment.id)).slice(0, 30);
+    const contentContext = this.buildContentContext(snapshot, account);
+    const directions = this.createThreeDirections(
+      accountId,
+      'pending_positioning_version',
+      evidenceIds,
+      commentRefs,
+      now,
+      contentContext,
+    );
+    const proposal = this.createPositioningProposal(snapshot, evidenceIds, mode, now, contentContext);
+    const topics = this.buildDirectionTopics(directions, snapshot, account);
+    for (const direction of directions) {
+      direction.topicIds = topics.filter((topic) => topic.directionId === direction.id).map((topic) => topic.id);
+    }
+    const tasks = this.createPriorityTasks(accountId, mode, evidenceIds, directions, now);
+    const scores = this.calculateScores(snapshot, account);
+    const reportMarkdown = this.buildReport(snapshot, mode, topWorks, scores, account);
+
+    return { mode, reportMarkdown, scores, proposal, directions, topics, tasks };
+  }
+
+  buildDirectionTopics(
+    directions: IpContentDirection[],
+    snapshot: IpAccountSnapshot,
+    account?: IpMonitoredAccount,
+  ): IpTopic[] {
+    const context = this.buildContentContext(snapshot, account);
+    const [growthTheme, trustTheme, interactionTheme] = context.pillars;
+    const painPoints = context.painPoints.length
+      ? context.painPoints
+      : [`用户缺少把${context.domain}落到实际操作的清晰步骤`];
+    const templates: Record<IpContentDirection['role'], Array<{ title: string; goal: string; problem: string }>> = {
+      growth: [
+        { title: `${growthTheme}:新手最容易做反的第一步`, goal: '用明确误区降低理解门槛并吸引新用户', problem: painPoints[0] },
+        { title: `从 0 开始做${context.domain},先判断这 3 件事`, goal: '给新用户一套可立即使用的判断框架', problem: painPoints[1] || painPoints[0] },
+        { title: `${context.domain}入门前,最容易忽略的 3 个前置条件`, goal: '把高频卡点整理成可传播的入门清单', problem: painPoints[2] || painPoints[0] },
+        { title: `别急着堆工具:${context.domain}真正有效的起步顺序`, goal: '用清晰顺序建立账号的专业认知', problem: '用户接触了很多零散信息,但不知道应该先做什么' },
+      ],
+      trust: [
+        { title: `${trustTheme}:从准备、执行到结果的完整过程`, goal: '用完整实测过程建立可信度', problem: painPoints[0] },
+        { title: `我如何判断一套${context.domain}方法是否真的有效`, goal: '输出可复用的验证标准并促进收藏', problem: '用户看到了结论,但缺少可以自己验证的标准' },
+        { title: `${context.domain}真实踩坑复盘:哪些做法看似正确却没结果`, goal: '用失败边界和修正过程增强专业可信度', problem: painPoints[1] || painPoints[0] },
+        { title: `${trustTheme}工具清单:适合谁、不适合谁、怎么选`, goal: '用具体选择建议建立决策信任', problem: '用户面对多个工具或方案时无法判断取舍' },
+      ],
+      interaction: [
+        { title: `${interactionTheme}:评论区最常见的卡点怎么解决`, goal: '把高频评论痛点沉淀为固定答疑栏目', problem: painPoints[0] },
+        { title: `${context.domain}实操答疑:遇到${painPoints[1] || painPoints[0]}怎么办`, goal: '围绕具体障碍引发追问和经验交流', problem: painPoints[1] || painPoints[0] },
+        { title: `你做${context.domain}时,最卡在准备、执行还是验证结果`, goal: '用结构化提问持续收集真实需求', problem: painPoints[2] || '用户有具体问题,但缺少清晰的表达入口' },
+        { title: `${interactionTheme}:本周 3 个真实问题集中回复`, goal: '把评论反馈变成稳定的内容栏目', problem: painPoints[3] || '高频问题没有被系统回应和持续跟进' },
+      ],
+    };
+
+    return directions.flatMap((direction) => templates[direction.role].map((item, index) => ({
+      id: `account_topic_${direction.accountId}_${direction.role}_${index + 1}`,
+      title: item.title,
+      directionId: direction.id,
+      column: direction.title,
+      trafficLayer: direction.role === 'growth' ? 'broad' : direction.role === 'trust' ? 'conversion' : 'vertical',
+      contentGoal: item.goal,
+      targetAudience: direction.targetAudience,
+      userProblem: item.problem,
+      evidenceRefs: direction.sourceEvidenceIds,
+      viralPatternRefs: direction.viralPatternRefs,
+      expectedRole: direction.role,
+      priority: index === 0 ? 'high' : index === 1 ? 'medium' : 'low',
+      fitReason: `服务「${direction.title}」,依据最近作品主题和评论痛点重新组织,不直接照搬原始文案。`,
+      source: `账号诊断 / ${direction.title}`,
+      requiredMaterials: direction.role === 'trust' ? ['真实操作过程、结果截图或案例证据'] : ['账号本人真实画面'],
+      scriptStatus: 'outline',
+      riskNote: '只迁移爆款结构,不照搬对标账号表达。',
+      qualityLevel: index === 0 ? 'make_now' : index === 1 ? 'polish_first' : 'needs_material',
+      productionReadinessScore: index === 0 ? 82 : index === 1 ? 72 : 58,
+    })));
+  }
+
+  createSnapshotForTest(workCount: number, commentsPerDeepWork: number): IpAccountSnapshot {
+    const now = new Date().toISOString();
+    return {
+      id: 'snapshot_test',
+      accountId: 'account_test',
+      profile: {
+        nickname: '测试账号',
+        signature: '传统行业老板增长笔记',
+      },
+      dataMode: workCount >= 6 ? 'data_diagnosis' : 'startup_hypothesis',
+      works: Array.from({ length: workCount }).map((_, index) => ({
+        id: `work_${index + 1}`,
+        accountId: 'account_test',
+        awemeId: `aweme_${index + 1}`,
+        title: `老板 IP 作品 ${index + 1}`,
+        desc: `老板 IP 作品 ${index + 1}`,
+        metrics: {
+          likeCount: 20 + index * 3,
+          commentCount: index,
+          collectCount: Math.max(0, index - 1),
+          shareCount: Math.max(0, index - 2),
+        },
+        interactionScore: 20 + index * 9,
+        isDeepSampled: index < 3,
+        comments: Array.from({ length: index < 3 ? commentsPerDeepWork : 0 }).map((__, commentIndex) => ({
+          id: `comment_${index + 1}_${commentIndex + 1}`,
+          workId: `work_${index + 1}`,
+          text: `评论问题 ${commentIndex + 1}`,
+          capturedAt: now,
+        })),
+        capturedAt: now,
+      })),
+      evidenceItemIds: [],
+      capturedAt: now,
+      warnings: [],
+    };
+  }
+
+  buildRefreshRetrospective(
+    previous: IpAccountSnapshot | null,
+    current: IpAccountSnapshot,
+    bindings: IpPublishWorkBinding[],
+    activeVersion: IpPositioningVersion | null,
+  ): AccountRefreshRetrospectiveResult {
+    const now = new Date().toISOString();
+    const previousWorks = new Map((previous?.works || []).map((work) => [work.awemeId, work]));
+    const matched = bindings
+      .filter((binding) => binding.status !== 'rejected' && !!binding.awemeId)
+      .map((binding) => ({
+        binding,
+        work: current.works.find((work) => work.awemeId === binding.awemeId),
+      }))
+      .filter((item): item is { binding: IpPublishWorkBinding; work: IpAccountSnapshot['works'][number] } => !!item.work);
+
+    const tasks: IpOperationTask[] = matched.map(({ binding, work }) => {
+      const before = previousWorks.get(work.awemeId);
+      const playDelta = Number(work.metrics.playCount || 0) - Number(before?.metrics.playCount || 0);
+      const interaction = Number(work.metrics.likeCount || 0)
+        + Number(work.metrics.commentCount || 0)
+        + Number(work.metrics.collectCount || 0)
+        + Number(work.metrics.shareCount || 0);
+      return {
+        id: `task_${current.accountId}_review_${binding.publishPackageId}`,
+        accountId: current.accountId,
+        column: 'today',
+        type: 'review',
+        title: `复盘已发布作品:${work.title}`,
+        reason: `当前播放 ${work.metrics.playCount || 0},本次新增 ${Math.max(0, playDelta)};互动合计 ${interaction}。优先检查开头钩子、评论问题和关注理由。`,
+        growthImpactScore: 92,
+        relatedEvidenceIds: current.evidenceItemIds,
+        createdAt: now,
+        updatedAt: now,
+      };
+    });
+
+    const positioningProposal = matched.length && activeVersion
+      ? {
+          id: `positioning_proposal_review_${current.accountId}_${Date.now()}`,
+          accountId: current.accountId,
+          snapshotId: current.id,
+          baseVersionId: activeVersion.id,
+          status: 'proposed' as const,
+          reason: `基于 ${matched.length} 条已绑定真实作品的最新数据提出定位迭代建议,需人工确认后生效。`,
+          proposedVersion: {
+            accountId: current.accountId,
+            targetAudience: activeVersion.targetAudience,
+            persona: activeVersion.persona,
+            followReason: activeVersion.followReason,
+            contentPillars: activeVersion.contentPillars,
+            expressionStyle: activeVersion.expressionStyle,
+            boundaries: activeVersion.boundaries,
+            suitableViralPatterns: activeVersion.suitableViralPatterns,
+            evidenceItemIds: activeVersion.evidenceItemIds,
+            assumptionsToValidate: [
+              ...activeVersion.assumptionsToValidate,
+              '已发布作品的数据变化是否支持继续强化当前内容方向',
+            ],
+            observeMetrics: activeVersion.observeMetrics,
+          },
+          evidenceItemIds: current.evidenceItemIds,
+          createdAt: now,
+          updatedAt: now,
+        }
+      : undefined;
+
+    return {
+      summaryMarkdown: [
+        '# 账号刷新复盘',
+        `本次刷新识别到 ${matched.length} 条已绑定真实作品。`,
+        ...matched.map(({ work }) => `- ${work.title}:播放 ${work.metrics.playCount || 0},评论 ${work.metrics.commentCount || 0},收藏 ${work.metrics.collectCount || 0},分享 ${work.metrics.shareCount || 0}`),
+        matched.length ? '已生成复盘任务,并提出待确认的定位迭代建议。' : '暂未匹配到已绑定作品,本次只更新账号快照。',
+      ].join('\n'),
+      tasks,
+      positioningProposal,
+    };
+  }
+
+  private buildContentContext(snapshot: IpAccountSnapshot, account?: IpMonitoredAccount): AccountContentContext {
+    const rankedWorks = [...snapshot.works].sort((a, b) => b.interactionScore - a.interactionScore);
+    const workTexts = rankedWorks
+      .slice(0, 8)
+      .map((work) => this.cleanSourceText(work.title || work.desc))
+      .filter(Boolean);
+    const commentTexts = rankedWorks
+      .flatMap((work) => work.comments.map((comment) => this.cleanSourceText(comment.text)))
+      .filter(Boolean);
+    const domain = this.inferDomain(account?.intendedTrack, snapshot.profile.signature, workTexts);
+    const painPoints = this.summarizeCommentPains(commentTexts, domain);
+    const targetAudience = account?.intendedAudience
+      || this.inferTargetAudience(commentTexts, domain);
+    const persona = account?.intendedPersona
+      || this.inferPersona(domain);
+
+    return {
+      domain,
+      targetAudience,
+      persona,
+      followReason: `持续获得${domain}的可执行教程、工具实测和真实踩坑复盘`,
+      pillars: [
+        `${domain}新手入门与认知纠偏`,
+        `${domain}工具实测与完整教程`,
+        `${domain}真实项目复盘与问题答疑`,
+      ],
+      painPoints,
+      workCount: snapshot.works.length,
+      commentCount: commentTexts.length,
+    };
+  }
+
+  private inferDomain(intendedTrack: string | undefined, signature: string, workTexts: string[]): string {
+    const intended = this.compactTheme(intendedTrack || '');
+    if (intended) return intended;
+
+    const source = `${workTexts.join(' ')} ${signature}`.toLowerCase();
+    const knownThemes: Array<[RegExp, string]> = [
+      [/vibe\s*coding/i, 'Vibe Coding'],
+      [/\bcodex\b.*\bskills?\b|\bskills?\b.*\bcodex\b/i, 'Codex Skills'],
+      [/\bcodex\b/i, 'Codex 实操'],
+      [/ai.{0,8}(编程|开发)|编程.{0,8}ai/i, 'AI 编程'],
+      [/ai.{0,8}(ppt|演示)|ppt.{0,8}ai/i, 'AI PPT'],
+      [/(小红书|xhs).{0,8}(运营|内容|账号)/i, '小红书运营'],
+      [/(抖音|短视频).{0,8}(运营|内容|账号)/i, '短视频运营'],
+      [/(个人\s*ip|ip\s*运营|ip\s*操盘)/i, '个人 IP 运营'],
+      [/(ai|人工智能).{0,8}(工具|工作流|效率)/i, 'AI 工具与工作流'],
+    ];
+    const matched = knownThemes.find(([pattern]) => pattern.test(source));
+    if (matched) return matched[1];
+
+    return this.compactTheme(signature) || '账号核心专业';
+  }
+
+  private inferTargetAudience(commentTexts: string[], domain: string): string {
+    const joined = commentTexts.join(' ');
+    if (/新手|小白|刚开始|从零|不会|不懂/i.test(joined)) {
+      return `希望从 0 到 1 上手${domain}的新手和职场用户`;
+    }
+    if (/老板|企业|客户|业务|团队/i.test(joined)) {
+      return `希望把${domain}用于真实业务的个人创作者和中小团队`;
+    }
+    return `希望把${domain}真正用起来,而不是只了解概念的学习者和职场用户`;
+  }
+
+  private inferPersona(domain: string): string {
+    if (/Vibe Coding|Codex|AI 编程/i.test(domain)) {
+      return `${domain}新手实操教练`;
+    }
+    return `${domain}实操与复盘型创作者`;
+  }
+
+  private summarizeCommentPains(commentTexts: string[], domain: string): string[] {
+    const joined = commentTexts.join(' ');
+    const pains: string[] = [];
+    const push = (condition: boolean, value: string) => {
+      if (condition && !pains.includes(value)) pains.push(value);
+    };
+
+    push(/安装|配置|环境|部署|下载|插件|skill/i.test(joined), '缺少从安装、配置到首次运行的完整步骤');
+    push(/api|密钥|key|额度|收费|付费|openai/i.test(joined), '不清楚接口、密钥、费用和工具依赖如何配置');
+    push(/怎么|如何|步骤|教程|指令|提示词|命令/i.test(joined), '需要可以直接照着执行的操作步骤和模板');
+    push(/失败|报错|不能|无法|没找到|找不到|不生效/i.test(joined), '照着操作后仍无法复现结果,缺少排错路径');
+    push(/区别|选择|哪个好|判断|是否|值不值得/i.test(joined), '面对多个方案时缺少简单、可信的选择标准');
+    push(/效果|结果|案例|真实|复盘/i.test(joined), '想看到真实结果、限制条件和失败复盘');
+
+    return (pains.length ? pains : [`用户缺少把${domain}落到真实场景的清晰路径`]).slice(0, 4);
+  }
+
+  private compactTheme(value: string): string {
+    const cleaned = this.cleanSourceText(value)
+      .replace(/^(专注|分享|记录|每天更新|一个)\s*/i, '')
+      .split(/[,。;、||]/)[0]
+      .trim();
+    if (!cleaned) return '';
+    return cleaned.length > 18 ? `${cleaned.slice(0, 18)}` : cleaned;
+  }
+
+  private cleanSourceText(value: string): string {
+    return String(value || '')
+      .replace(/https?:\/\/\S+/gi, ' ')
+      .replace(/\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/gi, ' ')
+      .replace(/(?:微信|vx|v信|邮箱|email|联系)[::\s]*[\w.-]+/gi, ' ')
+      .replace(/#[^#\s]+/g, ' ')
+      .replace(/@[\w\u4e00-\u9fa5-]+/g, ' ')
+      .replace(/\s+/g, ' ')
+      .trim();
+  }
+
+  private createPositioningProposal(
+    snapshot: IpAccountSnapshot,
+    evidenceIds: string[],
+    mode: 'data_diagnosis' | 'startup_hypothesis',
+    now: string,
+    context: AccountContentContext,
+  ): IpPositioningProposal {
+    const accountId = snapshot.accountId;
+    return {
+      id: `positioning_proposal_${accountId}_${Date.now()}`,
+      accountId,
+      snapshotId: snapshot.id,
+      status: 'proposed',
+      reason: mode === 'data_diagnosis'
+        ? `基于最近 ${context.workCount} 条作品和 ${context.commentCount} 条高互动评论样本生成,原始文案仅作为证据,不直接作为定位结论。`
+        : '账号作品样本不足,当前提案是基于主页信息和用户运营意图形成的待验证假设。',
+      proposedVersion: {
+        accountId,
+        targetAudience: context.targetAudience,
+        persona: context.persona,
+        followReason: context.followReason,
+        contentPillars: context.pillars,
+        expressionStyle: ['口语化', '具体案例优先', '前 3 秒直接给判断'],
+        boundaries: ['不照搬对标账号话术', '不使用夸大承诺', '不做与账号定位无关的泛流量内容'],
+        suitableViralPatterns: ['反常识开头', '问题-方案结构', '评论问题反推选题'],
+        evidenceItemIds: evidenceIds,
+        assumptionsToValidate: [
+          `目标用户是否愿意持续关注${context.domain}的实操与复盘内容`,
+          '三个内容主题中哪个能同时带来播放、互动和关注',
+        ],
+        observeMetrics: ['播放量', '点赞数', '评论数', '收藏数', '分享数', '涨粉'],
+      },
+      evidenceItemIds: evidenceIds,
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private createThreeDirections(
+    accountId: string,
+    positioningVersionId: string,
+    evidenceIds: string[],
+    commentRefs: string[],
+    now: string,
+    context: AccountContentContext,
+  ): IpContentDirection[] {
+    return [
+      {
+        id: `direction_${accountId}_growth`,
+        accountId,
+        positioningVersionId,
+        role: 'growth',
+        title: context.pillars[0],
+        targetAudience: context.targetAudience,
+        purpose: `围绕${context.domain}的入门误区、判断标准和起步顺序,帮助新用户快速理解并产生关注预期。`,
+        sourceEvidenceIds: evidenceIds,
+        viralPatternRefs: ['反常识开头', '强问题钩子'],
+        commentPainRefs: commentRefs.slice(0, 8),
+        topicIds: [],
+        cadenceSuggestion: '每周 2-3 条',
+        observeMetrics: ['播放量', '分享数', '新粉'],
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: `direction_${accountId}_trust`,
+        accountId,
+        positioningVersionId,
+        role: 'trust',
+        title: context.pillars[1],
+        targetAudience: `正在比较${context.domain}工具、方法和执行路径的用户`,
+        purpose: `通过真实操作、工具对比、完整教程和结果验证,证明账号不是只讲概念。`,
+        sourceEvidenceIds: evidenceIds,
+        viralPatternRefs: ['问题-方案结构', '案例复盘结构'],
+        commentPainRefs: commentRefs.slice(8, 16),
+        topicIds: [],
+        cadenceSuggestion: '每周 1-2 条',
+        observeMetrics: ['收藏数', '评论质量', '关注率'],
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: `direction_${accountId}_interaction`,
+        accountId,
+        positioningVersionId,
+        role: 'interaction',
+        title: context.pillars[2],
+        targetAudience: `已经开始实践${context.domain}、遇到具体问题并愿意追问的用户`,
+        purpose: `把高互动评论归纳成问题类型,用真实项目复盘和集中答疑形成持续栏目。`,
+        sourceEvidenceIds: evidenceIds,
+        viralPatternRefs: ['评论问题反推选题', '共鸣提问结尾'],
+        commentPainRefs: commentRefs.slice(0, 20),
+        topicIds: [],
+        cadenceSuggestion: '每周 1-2 条',
+        observeMetrics: ['评论数', '评论率', '高频问题数量'],
+        createdAt: now,
+        updatedAt: now,
+      },
+    ];
+  }
+
+  private createPriorityTasks(
+    accountId: string,
+    mode: string,
+    evidenceIds: string[],
+    directions: IpContentDirection[],
+    now: string,
+  ): IpOperationTask[] {
+    return [
+      {
+        id: `task_${accountId}_positioning`,
+        accountId,
+        column: 'today',
+        type: 'positioning',
+        title: mode === 'data_diagnosis' ? '确认账号当前定位提案' : '确认起号定位假设',
+        reason: '定位版本会约束后续方向、选题、脚本和发布包。',
+        growthImpactScore: 95,
+        relatedEvidenceIds: evidenceIds,
+        createdAt: now,
+        updatedAt: now,
+      },
+      ...directions.map((direction, index) => ({
+        id: `task_${accountId}_direction_${direction.role}`,
+        accountId,
+        column: index === 0 ? 'today' as const : 'this_week' as const,
+        type: 'direction' as const,
+        title: `确认${direction.title}`,
+        reason: `${direction.purpose} 方向稳定后,选题和脚本才能形成系列感。`,
+        growthImpactScore: direction.role === 'growth' ? 90 : direction.role === 'interaction' ? 84 : 80,
+        relatedEvidenceIds: evidenceIds,
+        relatedDirectionId: direction.id,
+        createdAt: now,
+        updatedAt: now,
+      })),
+      {
+        id: `task_${accountId}_comments`,
+        accountId,
+        column: 'this_week',
+        type: 'pain',
+        title: '复核重点作品评论痛点',
+        reason: '评论问题能反推互动型选题和脚本切入点。',
+        growthImpactScore: 82,
+        relatedEvidenceIds: evidenceIds,
+        createdAt: now,
+        updatedAt: now,
+      },
+    ];
+  }
+
+  private buildReport(
+    snapshot: IpAccountSnapshot,
+    mode: string,
+    topWorks: IpAccountSnapshot['works'],
+    scores: IpAccountDiagnosisScores,
+    account?: IpMonitoredAccount,
+  ): string {
+    const deepCommentCount = snapshot.works.reduce((sum, work) => sum + work.comments.length, 0);
+    const topTitles = topWorks.map((item) => item.title);
+    const commentTexts = snapshot.works.flatMap((work) => work.comments.map((comment) => comment.text)).filter(Boolean);
+    const questionSamples = commentTexts.filter((text) => /怎么|如何|为什么|能不能|有没有|想知道|请问|吗|?|\?/.test(text)).slice(0, 5);
+    const topStructures = topWorks.map((work) => work.structure?.style).filter(Boolean);
+    const actualThemes = topWorks.map((work) => work.structure?.topic || work.title).filter(Boolean).slice(0, 3);
+    const intended = [account?.intendedTrack, account?.intendedPersona, account?.intendedAudience].filter(Boolean).join(' / ');
+    const alignment = intended
+      ? (actualThemes.some((theme) => intended.includes(theme) || theme.includes(account?.intendedTrack || '__none__'))
+          ? '用户期望方向与高互动内容存在重合,可以继续收窄表达。'
+          : '用户期望方向与当前高互动内容尚未形成稳定重合,需要通过 7 天测试验证。')
+      : '尚未补充期望赛道、人设和目标受众,当前只能从真实内容反推定位。';
+    return [
+      '# 账号诊断报告',
+      '',
+      `账号:${snapshot.profile.nickname || snapshot.accountId}`,
+      `诊断模式:${mode === 'data_diagnosis' ? '数据诊断模式' : '起号假设模式'}`,
+      `作品样本:${snapshot.works.length} 条`,
+      `重点评论样本:${deepCommentCount} 条`,
+      topTitles.length ? `高互动作品:${topTitles.join('、')}` : '高互动作品:暂无',
+      '',
+      '## 账号快照',
+      `- 简介:${snapshot.profile.signature || '未填写'}`,
+      `- 粉丝:${snapshot.profile.followerCount ?? '未知'};获赞:${snapshot.profile.totalFavorited ?? '未知'};作品:${snapshot.profile.awemeCount ?? snapshot.works.length}`,
+      `- 用户期望方向:${intended || '未补充'}`,
+      '',
+      '## 五维诊断评分',
+      `- 定位清晰度:${scores.positioningClarity}/100`,
+      `- 内容结构力:${scores.contentStructure}/100`,
+      `- 互动转化力:${scores.interactionConversion}/100`,
+      `- 账号辨识度:${scores.recognizability}/100`,
+      `- 增长可持续性:${scores.sustainability}/100`,
+      '',
+      '## 当前账号像什么 IP',
+      `- 当前更像:${account?.intendedPersona || snapshot.profile.nickname || '待识别的内容型账号'}`,
+      `- 实际内容主题:${actualThemes.length ? actualThemes.join('、') : '样本不足'}`,
+      `- 期望与实际是否一致:${alignment}`,
+      '',
+      '## 当前内容实际吸引的人群',
+      mode === 'data_diagnosis'
+        ? `- 高互动内容正在吸引对「${actualThemes[0] || '当前主题'}」有明确问题的人群。`
+        : `- 作品样本不足,暂按「${account?.intendedAudience || '待验证目标受众'}」作为起号假设。`,
+      '',
+      '## 高互动作品共性',
+      `- 常见结构:${topStructures.length ? [...new Set(topStructures)].join('、') : '尚未形成稳定结构'}`,
+      `- 共同主题:${actualThemes.length ? actualThemes.join('、') : '样本不足'}`,
+      '- 更容易获得互动的内容通常先给明确判断,再进入具体问题或案例。',
+      '',
+      '## 评论区用户关心的问题',
+      ...(questionSamples.length ? questionSamples.map((text) => `- ${text}`) : ['- 当前评论问题样本不足,需要继续刷新高互动作品评论。']),
+      '',
+      '## 当前最大增长障碍',
+      snapshot.works.length < 6
+        ? '- 内容样本不足,无法判断稳定的关注理由和可持续方向。'
+        : actualThemes.length > 2
+          ? '- 高互动内容主题较分散,用户可能难以形成稳定的关注预期。'
+          : '- 已有内容信号,但需要把高互动主题固化成系列并持续验证关注转化。',
+      '',
+      '## 当前判断',
+      mode === 'data_diagnosis'
+        ? '当前账号已有一定作品样本,应优先从高互动作品和评论区反馈中提炼定位。'
+        : '当前账号内容样本不足,应先用对标证据和 7 天测试验证定位假设。',
+      '',
+      '## 未来 7 天优先动作',
+      '1. 确认或修改定位版本,明确目标人群和关注理由。',
+      '2. 从涨粉破圈、信任建立、互动共鸣三个方向各选择至少 1 个选题。',
+      '3. 优先拍摄一条强观点内容和一条真实案例内容。',
+      '4. 发布后绑定真实作品,刷新数据并复核评论区的新问题。',
+    ].join('\n');
+  }
+
+  private calculateScores(snapshot: IpAccountSnapshot, account?: IpMonitoredAccount): IpAccountDiagnosisScores {
+    const works = snapshot.works;
+    const deepComments = works.reduce((sum, work) => sum + work.comments.length, 0);
+    const structuredWorks = works.filter((work) => work.structure?.hook && work.structure?.topic).length;
+    const themes = works.map((work) => work.structure?.topic).filter(Boolean) as string[];
+    const uniqueThemes = new Set(themes).size;
+    const recurringThemeRatio = themes.length ? Math.max(0, 1 - (uniqueThemes - 1) / Math.max(1, themes.length)) : 0;
+    const intendedCount = [account?.intendedTrack, account?.intendedPersona, account?.intendedAudience].filter(Boolean).length;
+    const interactionWorks = works.filter((work) =>
+      Number(work.metrics.commentCount || 0) + Number(work.metrics.collectCount || 0) + Number(work.metrics.shareCount || 0) > 0,
+    ).length;
+    const clamp = (value: number) => Math.max(0, Math.min(100, Math.round(value)));
+
+    return {
+      positioningClarity: clamp(30 + intendedCount * 12 + (snapshot.profile.signature ? 14 : 0) + recurringThemeRatio * 20),
+      contentStructure: clamp(25 + (structuredWorks / Math.max(1, works.length)) * 55 + Math.min(20, works.length * 2)),
+      interactionConversion: clamp(20 + (interactionWorks / Math.max(1, works.length)) * 45 + Math.min(35, deepComments)),
+      recognizability: clamp(25 + recurringThemeRatio * 50 + (snapshot.profile.signature ? 15 : 0)),
+      sustainability: clamp(20 + Math.min(45, works.length * 5) + Math.min(20, deepComments / 2) + (intendedCount === 3 ? 15 : 0)),
+    };
+  }
+}

+ 711 - 0
src/app/services/ip-account-monitor.service.spec.ts

@@ -0,0 +1,711 @@
+import { of } from 'rxjs';
+import { IpPublishPackage } from '../models/ip-operator.model';
+import { IpAccountMonitorService } from './ip-account-monitor.service';
+
+describe('IpAccountMonitorService', () => {
+  let service: IpAccountMonitorService;
+
+  beforeEach(() => {
+    service = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({}),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoDetail: () => of({}),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+  });
+
+  it('extracts account input from Douyin homepage and ids', () => {
+    const homepage = service.extractAccountInput('https://www.douyin.com/user/MS4wLjABAAAA_demo?from_tab_name=main');
+    const numericHomepage = service.extractAccountInput('https://www.douyin.com/user/917997605?from_tab_name=main');
+
+    expect(homepage.kind).toBe('homepage');
+    expect(homepage.identifierKind).toBe('sec_uid');
+    expect(numericHomepage.identifierKind).toBe('unique_id');
+    expect(service.extractAccountInput('MS4wLjABAAAA_demo').identifierKind).toBe('sec_uid');
+    expect(service.extractAccountInput('owner_note_123').identifierKind).toBe('unique_id');
+  });
+
+  it('prioritizes high-interaction works for deeper comment sampling', () => {
+    const ranked = service.rankWorksForDeepCommentSampling([
+      service.createWorkForTest('a', { likeCount: 100, commentCount: 1, collectCount: 0, shareCount: 0 }),
+      service.createWorkForTest('b', { likeCount: 20, commentCount: 30, collectCount: 0, shareCount: 0 }),
+    ], 1);
+
+    expect(ranked[0].awemeId).toBe('b');
+  });
+
+  it('stores unique evidence ids on a refreshed account snapshot', () => {
+    const snapshot = service.attachEvidenceIdsToSnapshot({
+      id: 'snapshot_1',
+      accountId: 'account_1',
+      dataMode: 'data_diagnosis',
+      capturedAt: '2026-06-15T00:00:00.000Z',
+      evidenceItemIds: [],
+      warnings: [],
+      profile: { nickname: 'FredTalk', signature: 'AI 工具' },
+      works: [],
+    }, ['evidence_1', 'evidence_2', 'evidence_1', '']);
+
+    expect(snapshot.evidenceItemIds).toEqual(['evidence_1', 'evidence_2']);
+  });
+
+  it('uses a stable current snapshot id for repeated refreshes of the same account', async () => {
+    const account = {
+      id: 'account-1',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      secUserId: 'MS4wLjABAAAA_stable',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    } as const;
+
+    const first = await service.refreshAccount(account);
+    const second = await service.refreshAccount(account);
+
+    expect(first.id).toBe('snapshot_account-1_current');
+    expect(second.id).toBe(first.id);
+  });
+
+  it('auto matches publish packages to real works by text similarity', () => {
+    const pkg = publishPackageFixture('老板 IP 为什么不能只拍产品');
+    const work = service.createWorkForTest('7304943423281171749', { likeCount: 10, commentCount: 3 });
+    const binding = service.matchPublishPackageToWorks(pkg, [{
+      ...work,
+      title: '老板 IP 为什么不能只拍产品',
+      desc: '老板 IP 为什么不能只拍产品,先讲信任再讲转化',
+      url: 'https://www.douyin.com/video/7304943423281171749',
+    }]);
+
+    expect(binding?.status).toBe('auto_matched');
+    expect(binding?.awemeId).toBe('7304943423281171749');
+  });
+
+  it('supports manual binding and rejection of auto matches', () => {
+    const pkg = publishPackageFixture('老板 IP');
+    const manual = service.createManualBinding(pkg, 'account-1', 'https://www.douyin.com/video/7304943423281171749');
+    const rejected = service.rejectBinding(manual);
+
+    expect(manual.status).toBe('manually_bound');
+    expect(manual.awemeId).toBe('7304943423281171749');
+    expect(rejected.status).toBe('rejected');
+  });
+
+  it('fetches a manually bound published work by aweme id', async () => {
+    const localService = new IpAccountMonitorService({
+      getVideoDetail: () => of({
+        aweme_detail: {
+          aweme_id: '7642895144848542991',
+          desc: '新手如何快速上手 Codex',
+          create_time: 1783260000,
+          statistics: {
+            play_count: 1200,
+            digg_count: 88,
+            comment_count: 9,
+            collect_count: 15,
+            share_count: 3,
+          },
+        },
+      }),
+      getVideoComments: () => of({
+        comments: [{
+          text: '想看具体安装步骤',
+          digg_count: 2,
+          user: { nickname: '用户A' },
+        }],
+      }),
+    } as any);
+
+    const work = await localService.fetchPublishedWorkByAwemeId('account-1', '7642895144848542991');
+
+    expect(work?.awemeId).toBe('7642895144848542991');
+    expect(work?.metrics.likeCount).toBe(88);
+    expect(work?.metrics.commentCount).toBe(9);
+    expect(work?.comments[0].text).toBe('想看具体安装步骤');
+  });
+
+  it('resolves sec_user_id from unique_id profile before fetching recent works', async () => {
+    const requestedSecIds: string[] = [];
+    const requestedPostParams: any[] = [];
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user: {
+            nickname: '林川老板增长笔记',
+            signature: '传统行业老板 IP',
+            sec_uid: 'MS4wLjABAAAA_resolved',
+            follower_count: 1200,
+          },
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: (params: any) => {
+        requestedSecIds.push(params.sec_user_id);
+        requestedPostParams.push(params);
+        return of({
+          aweme_list: [{
+            aweme_id: '7304943423281171749',
+            desc: '老板 IP 为什么不能只拍产品',
+            statistics: { digg_count: 10, comment_count: 2, collect_count: 1, share_count: 1 },
+          }],
+        });
+      },
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-1',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: '待刷新账号',
+      uniqueId: 'linchuan',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(requestedSecIds).toEqual(['MS4wLjABAAAA_resolved']);
+    expect(requestedPostParams[0]).toMatchObject({ max_cursor: 0, sort_type: 0, filter_type: 0, count: 10 });
+    expect(snapshot.profile.nickname).toBe('林川老板增长笔记');
+    expect(snapshot.works[0].awemeId).toBe('7304943423281171749');
+  });
+
+  it('unwraps gateway data for profile and recent works', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        code: 200,
+        data: {
+          status_code: 0,
+          user_info: {
+            nickname: 'Wrapped profile',
+            sec_uid: 'MS4wLjABAAAA_wrapped',
+          },
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({
+        code: 200,
+        data: {
+          status_code: 0,
+          aweme_list: [{
+            aweme_id: '7304943423281171750',
+            desc: 'Wrapped work',
+            statistics: { digg_count: 9, comment_count: 1 },
+          }],
+        },
+      }),
+      getVideoComments: () => of({ code: 200, data: { status_code: 0, comments: [] } }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-wrapped',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'Pending',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.nickname).toBe('Wrapped profile');
+    expect(snapshot.works.map((work) => work.awemeId)).toEqual(['7304943423281171750']);
+  });
+
+  it('reads nested pagination state when loading account works', async () => {
+    const requestedPostParams: any[] = [];
+    const localService = new IpAccountMonitorService({
+      getUserPosts: (params: any) => {
+        requestedPostParams.push(params);
+        return of({
+          data: {
+            data: {
+              aweme_list: [{
+                aweme_id: '7304943423281171751',
+                desc: 'Nested page work',
+                statistics: { digg_count: 9, comment_count: 1 },
+              }],
+              has_more: 1,
+              max_cursor: '20',
+            },
+          },
+        });
+      },
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const page = await localService.fetchAccountWorksPage({
+      id: 'account-nested-page',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      secUserId: 'MS4w_nested_page',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    }, { count: 10, includeCommentsForTop: 0 });
+
+    expect(requestedPostParams[0]).toMatchObject({ max_cursor: 0, sort_type: 0, filter_type: 0, count: 10 });
+    expect(page.works[0].awemeId).toBe('7304943423281171751');
+    expect(page.cursor).toBe('20');
+    expect(page.hasMore).toBe(true);
+    expect(page.pageStateReliable).toBe(true);
+  });
+
+  it('keeps transcript-like fields on refreshed work snapshots', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({}),
+      getUserProfile: () => of({ data: { user: { nickname: 'FredTalk', sec_uid: 'MS4w_transcript' } } }),
+      getUserPosts: () => of({
+        aweme_list: [{
+          aweme_id: '7304943423281171760',
+          desc: '新手工具选择顺序',
+          asr_text: '新手先判断任务,再选择对应工具。视频逐步说明安装顺序、常见误区和遇到问题时如何排查。',
+          statistics: { digg_count: 9, comment_count: 1 },
+        }],
+      }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-transcript',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      secUserId: 'MS4w_transcript',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.works[0].transcript).toMatchObject({
+      source: 'asr',
+      confidence: 'high',
+    });
+    expect(snapshot.works[0].transcript?.text).toContain('先判断任务');
+  });
+
+  it('enriches unique-id web profile with sec-user app profile like blogger monitoring', async () => {
+    const requestedDetailSecIds: string[] = [];
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_detail_profile',
+            unique_id: '917997605',
+            avatar_larger: { url_list: ['https://example.com/fred-larger.jpeg'] },
+            statistics: {
+              total_favorited: '175,144',
+              video_count: '53',
+            },
+          },
+        },
+      }),
+      getUserProfile: (secUserId: string) => {
+        requestedDetailSecIds.push(secUserId);
+        return of({
+          data: JSON.stringify({
+            user: {
+              nickname: 'FredTalk',
+              sec_uid: secUserId,
+              avatar: 'tos-cn-i-broken-avatar',
+              fans_count: '2.4万',
+              statistics: {
+                following_count: '300',
+              },
+            },
+          }),
+        });
+      },
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-detail-profile',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(requestedDetailSecIds).toEqual(['MS4w_detail_profile']);
+    expect(snapshot.profile.followerCount).toBe(24000);
+    expect(snapshot.profile.followingCount).toBe(300);
+    expect(snapshot.profile.totalFavorited).toBe(175144);
+    expect(snapshot.profile.awemeCount).toBe(53);
+    expect(snapshot.profile.avatarUrl).toBe('https://example.com/fred-larger.jpeg');
+    expect(snapshot.profile.avatarCandidates).toEqual(['https://example.com/fred-larger.jpeg']);
+  });
+
+  it('does not let a zero app detail count overwrite a valid web profile count', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_zero_detail',
+            follower_count: 24000,
+            statistics: {
+              total_favorited: 175144,
+              video_count: 53,
+            },
+          },
+        },
+      }),
+      getUserProfile: () => of({
+        data: {
+          user: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_zero_detail',
+            follower_count: 0,
+            total_favorited: 0,
+            aweme_count: 0,
+          },
+        },
+      }),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-zero-detail',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.followerCount).toBe(24000);
+    expect(snapshot.profile.totalFavorited).toBe(175144);
+    expect(snapshot.profile.awemeCount).toBe(53);
+  });
+
+  it('keeps the larger public work count when app detail returns a smaller count', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_smaller_detail',
+            statistics: {
+              video_count: 75,
+            },
+          },
+        },
+      }),
+      getUserProfile: () => of({
+        data: {
+          user: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_smaller_detail',
+            aweme_count: 53,
+          },
+        },
+      }),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-smaller-detail',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.awemeCount).toBe(75);
+  });
+
+  it('ignores non-http avatar strings and picks the same ordered candidates as blogger monitoring', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'Avatar account',
+            sec_uid: 'MS4w_avatar_candidates',
+            avatar: 'tos-cn-i-broken-avatar',
+            avatar_thumb: { url_list: ['https://example.com/thumb.jpeg'] },
+            avatar_medium: { url_list: ['https://example.com/medium.jpeg'] },
+            avatar_larger: { url_list: ['https://example.com/larger.jpeg'] },
+          },
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-avatar',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'Avatar account',
+      uniqueId: 'avatar-account',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.avatarUrl).toBe('https://example.com/larger.jpeg');
+    expect(snapshot.profile.avatarCandidates).toEqual([
+      'https://example.com/larger.jpeg',
+      'https://example.com/medium.jpeg',
+      'https://example.com/thumb.jpeg',
+    ]);
+  });
+
+  it('reads follower counts from voc-social compatible aliases and nested stats', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'FredTalk',
+            sec_uid: 'MS4w_fans_alias',
+            fans_count: '1.8万',
+            statistics: {
+              following_count: { count: '300' },
+              total_favorited: '175,144',
+              video_count: '53',
+            },
+          },
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-fans-alias',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.followerCount).toBe(18000);
+    expect(snapshot.profile.followingCount).toBe(300);
+    expect(snapshot.profile.totalFavorited).toBe(175144);
+    expect(snapshot.profile.awemeCount).toBe(53);
+  });
+
+  it('keeps missing follower counts undefined instead of inventing zero', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: {
+          user_info: {
+            nickname: 'Missing fans',
+            sec_uid: 'MS4w_missing_fans',
+          },
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({ aweme_list: [] }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-missing-fans',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'Missing fans',
+      uniqueId: 'missing-fans',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    });
+
+    expect(snapshot.profile.followerCount).toBeUndefined();
+  });
+
+  it('uses stable comment slot ids even when upstream comment ids change between refreshes', async () => {
+    let refreshIndex = 0;
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: { user_info: { nickname: 'Stable comments', sec_uid: 'MS4w_comments' } },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({
+        data: {
+          aweme_list: [Array.from({ length: 6 }, (_, index) => ({
+            aweme_id: `73049434232811717${index}`,
+            desc: `Work ${index}`,
+            statistics: { digg_count: 100 - index, comment_count: 10 },
+          }))][0],
+        },
+      }),
+      getVideoComments: () => {
+        refreshIndex += 1;
+        return of({
+          comments: [
+            { cid: `upstream-${refreshIndex}-a`, text: '怎么开始做账号' },
+            { cid: `upstream-${refreshIndex}-b`, text: '有没有步骤模板' },
+          ],
+        });
+      },
+    } as any);
+    const account = {
+      id: 'account-comments',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'Stable comments',
+      uniqueId: 'stable-comments',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    } as const;
+
+    const first = await localService.refreshAccount(account);
+    const second = await localService.refreshAccount(account);
+
+    expect(first.works[0].comments.map((item) => item.id)).toEqual([
+      'work_account-comments_730494342328117170_comment_1',
+      'work_account-comments_730494342328117170_comment_2',
+    ]);
+    expect(second.works[0].comments.map((item) => item.id)).toEqual(first.works[0].comments.map((item) => item.id));
+    expect(second.works[0].comments.map((item) => item.text)).toEqual(['怎么开始做账号', '有没有步骤模板']);
+  });
+
+  it('prioritizes real cover fields before preview frames', async () => {
+    const heic = 'https://p26-sign.douyinpic.com/cover.heic?x-signature=heic';
+    const jpeg = 'https://p26-sign.douyinpic.com/cover.jpeg?x-signature=jpeg';
+    const originJpeg = 'https://p3-sign.douyinpic.com/origin.jpeg?x-signature=origin';
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        data: { user_info: { nickname: 'FredTalk', sec_uid: 'MS4w_cover_test' } },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({
+        data: {
+          aweme_list: [{
+            aweme_id: '7636915997076770089',
+            desc: 'Skills test',
+            video: {
+              cover: { url_list: [heic, jpeg] },
+              origin_cover: { url_list: [originJpeg] },
+            },
+            statistics: {},
+          }],
+        },
+      }),
+      getVideoComments: () => of({ comments: [] }),
+    } as any);
+
+    const snapshot = await localService.refreshAccount({
+      id: 'account-cover',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'FredTalk',
+      uniqueId: '917997605',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-15T00:00:00.000Z',
+      updatedAt: '2026-06-15T00:00:00.000Z',
+    });
+
+    expect(snapshot.works[0].coverUrl).toBe(heic);
+    expect(snapshot.works[0].coverSource).toBe('platform_cover');
+    expect(snapshot.works[0].coverCandidates).toEqual([heic, jpeg, originJpeg]);
+    expect(snapshot.works[0].coverCandidateSources).toEqual({
+      [heic]: 'platform_cover',
+      [jpeg]: 'platform_cover',
+      [originJpeg]: 'origin_preview',
+    });
+  });
+
+  it('reports upstream business errors instead of treating them as empty data', async () => {
+    const localService = new IpAccountMonitorService({
+      getUserProfileByUniqueId: () => of({
+        code: 200,
+        data: {
+          status_code: 5,
+          status_msg: 'invalid parameter',
+        },
+      }),
+      getUserProfile: () => of({}),
+      getUserPosts: () => of({}),
+      getVideoComments: () => of({}),
+    } as any);
+
+    await expect(localService.refreshAccount({
+      id: 'account-invalid',
+      userId: 'user-1',
+      platform: 'douyin',
+      role: 'owned',
+      displayName: 'Pending',
+      uniqueId: 'invalid-owner',
+      enabled: true,
+      lastRefreshStatus: 'idle',
+      createdAt: '2026-06-12T00:00:00.000Z',
+      updatedAt: '2026-06-12T00:00:00.000Z',
+    })).rejects.toThrow('invalid parameter');
+  });
+});
+
+function publishPackageFixture(text: string): IpPublishPackage {
+  const now = new Date().toISOString();
+  return {
+    id: 'publish-1',
+    calendarItemId: 'calendar-1',
+    topicId: 'topic-1',
+    platform: 'douyin',
+    accountName: '测试账号',
+    titleOptions: [text],
+    coverSuggestions: [],
+    caption: text,
+    scriptText: text,
+    hashtags: [],
+    materialFiles: [],
+    isOriginal: true,
+    isDraft: true,
+    materialChecklist: [],
+    riskChecklist: [],
+    evidenceItemIds: [],
+    painInsightIds: [],
+    status: 'draft',
+    createdAt: now,
+    updatedAt: now,
+  };
+}

+ 894 - 0
src/app/services/ip-account-monitor.service.ts

@@ -0,0 +1,894 @@
+import { Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import {
+  IpAccountCommentSample,
+  IpAccountProfileSnapshot,
+  IpAccountSnapshot,
+  IpAccountWorkImageSource,
+  IpAccountWorkMetricSnapshot,
+  IpAccountWorkSnapshot,
+  IpAccountWorkTranscriptSource,
+  IpMonitoredAccount,
+  IpPublishPackage,
+  IpPublishWorkBinding,
+} from '../models/ip-operator.model';
+import { DouyinApiService } from './douyin-api.service';
+
+export interface ParsedIpAccountInput {
+  kind: 'homepage' | 'sec_uid' | 'unique_id' | 'unknown';
+  identifierKind: 'sec_uid' | 'unique_id' | 'unknown';
+  raw: string;
+  value: string;
+}
+
+export interface IpAccountWorksPageResult {
+  works: IpAccountWorkSnapshot[];
+  comments: IpAccountCommentSample[];
+  cursor?: string;
+  hasMore: boolean;
+  pageStateReliable: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountMonitorService {
+  constructor(private readonly douyin: DouyinApiService) {}
+
+  extractAccountInput(rawInput: string): ParsedIpAccountInput {
+    const raw = String(rawInput || '').trim();
+    if (!raw) return { kind: 'unknown', identifierKind: 'unknown', raw, value: '' };
+
+    const userPathMatch = raw.match(/douyin\.com\/user\/([^/?#\s]+)/i);
+    if (userPathMatch?.[1]) {
+      const value = decodeURIComponent(userPathMatch[1]);
+      return {
+        kind: 'homepage',
+        identifierKind: /^MS4w/i.test(value) ? 'sec_uid' : this.isUniqueId(value) ? 'unique_id' : 'unknown',
+        raw,
+        value,
+      };
+    }
+
+    const secUidMatch = raw.match(/(?:sec_uid|sec_user_id)=([^&#\s]+)/i);
+    if (secUidMatch?.[1]) {
+      return { kind: 'sec_uid', identifierKind: 'sec_uid', raw, value: decodeURIComponent(secUidMatch[1]) };
+    }
+
+    if (/^MS4w/i.test(raw)) {
+      return { kind: 'sec_uid', identifierKind: 'sec_uid', raw, value: raw };
+    }
+
+    if (this.isUniqueId(raw)) {
+      return { kind: 'unique_id', identifierKind: 'unique_id', raw, value: raw };
+    }
+
+    return { kind: 'unknown', identifierKind: 'unknown', raw, value: raw };
+  }
+
+  interactionScore(metrics: IpAccountWorkMetricSnapshot): number {
+    return Number(metrics.likeCount || 0)
+      + Number(metrics.commentCount || 0) * 4
+      + Number(metrics.collectCount || 0) * 3
+      + Number(metrics.shareCount || 0) * 5;
+  }
+
+  rankWorksForDeepCommentSampling(works: IpAccountWorkSnapshot[], limit = 5): IpAccountWorkSnapshot[] {
+    return [...works]
+      .sort((a, b) => b.interactionScore - a.interactionScore)
+      .slice(0, Math.max(0, limit));
+  }
+
+  attachEvidenceIdsToSnapshot(snapshot: IpAccountSnapshot, evidenceItemIds: string[]): IpAccountSnapshot {
+    return {
+      ...snapshot,
+      evidenceItemIds: [...new Set(evidenceItemIds.map((id) => String(id || '').trim()).filter(Boolean))],
+    };
+  }
+
+  createWorkForTest(awemeId: string, metrics: IpAccountWorkMetricSnapshot): IpAccountWorkSnapshot {
+    return {
+      id: `work_${awemeId}`,
+      accountId: 'account_test',
+      awemeId,
+      title: awemeId,
+      desc: awemeId,
+      metrics,
+      interactionScore: this.interactionScore(metrics),
+      isDeepSampled: false,
+      comments: [],
+      capturedAt: new Date().toISOString(),
+    };
+  }
+
+  async refreshAccount(account: IpMonitoredAccount): Promise<IpAccountSnapshot> {
+    const now = new Date().toISOString();
+    const profile = await this.fetchProfile(account);
+    const resolvedAccount = {
+      ...account,
+      secUserId: account.secUserId || (profile as any).secUserId,
+      uniqueId: account.uniqueId || (profile as any).uniqueId,
+    };
+    const firstPage = await this.fetchAccountWorksPage(resolvedAccount, {
+      count: 10,
+      includeCommentsForTop: 5,
+    }, now);
+    const enrichedWorks = firstPage.works;
+
+    return {
+      id: this.currentSnapshotId(account.id),
+      accountId: account.id,
+      profile,
+      works: enrichedWorks,
+      workPageInfo: {
+        fetchedCount: enrichedWorks.length,
+        hasMore: firstPage.hasMore,
+        cursor: firstPage.cursor,
+        lastLoadedAt: now,
+        source: 'initial',
+        pageStateReliable: firstPage.pageStateReliable,
+      },
+      dataMode: enrichedWorks.length >= 6 ? 'data_diagnosis' : 'startup_hypothesis',
+      evidenceItemIds: [],
+      capturedAt: now,
+      warnings: enrichedWorks.length >= 6 ? [] : ['账号作品样本不足,当前按起号假设模式分析。'],
+    };
+  }
+
+  matchPublishPackageToWorks(pkg: IpPublishPackage, works: IpAccountWorkSnapshot[]): IpPublishWorkBinding | null {
+    const targetText = [
+      pkg.caption,
+      ...(pkg.titleOptions || []),
+      pkg.scriptText,
+    ].filter(Boolean).join(' ');
+
+    const candidates = works.map((work) => {
+      const score = this.similarity(targetText, `${work.title} ${work.desc}`);
+      return { work, score };
+    }).sort((a, b) => b.score - a.score);
+
+    const best = candidates[0];
+    if (!best || best.score < 0.25) return null;
+
+    const now = new Date().toISOString();
+    return {
+      id: `publish_binding_${pkg.id}_${best.work.awemeId}`,
+      publishPackageId: pkg.id,
+      accountId: best.work.accountId,
+      awemeId: best.work.awemeId,
+      workUrl: best.work.url,
+      status: 'auto_matched',
+      matchScore: Math.round(best.score * 100),
+      matchReason: '根据标题/文案相似度自动匹配,请人工复核。',
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  createManualBinding(pkg: IpPublishPackage, accountId: string, workUrl: string): IpPublishWorkBinding {
+    const now = new Date().toISOString();
+    const awemeId = this.extractAwemeId(workUrl);
+    return {
+      id: `publish_binding_${pkg.id}_${awemeId || Date.now()}`,
+      publishPackageId: pkg.id,
+      accountId,
+      awemeId,
+      workUrl,
+      status: 'manually_bound',
+      matchScore: 100,
+      matchReason: '用户手动粘贴作品链接绑定。',
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  async fetchPublishedWorkByAwemeId(accountId: string, awemeId: string): Promise<IpAccountWorkSnapshot | null> {
+    const normalizedAwemeId = this.asText(awemeId);
+    if (!normalizedAwemeId) return null;
+
+    const capturedAt = new Date().toISOString();
+    const response = await firstValueFrom(this.douyin.getVideoDetail(normalizedAwemeId));
+    this.assertBusinessSuccess(response, '作品详情');
+    const raw = this.findWorkObject(response, normalizedAwemeId);
+    if (!raw) {
+      throw new Error('作品详情接口未返回可识别的作品数据。');
+    }
+
+    const work = this.normalizeWork({
+      ...raw,
+      aweme_id: raw.aweme_id || raw.awemeId || normalizedAwemeId,
+    }, accountId, 0, capturedAt);
+    let comments: IpAccountCommentSample[] = [];
+    try {
+      comments = await this.fetchComments(work.awemeId || normalizedAwemeId, work.id, capturedAt);
+    } catch {
+      comments = [];
+    }
+    const merged: IpAccountWorkSnapshot = {
+      ...work,
+      id: `work_${accountId}_${work.awemeId || normalizedAwemeId}`,
+      accountId,
+      awemeId: work.awemeId || normalizedAwemeId,
+      url: work.url || `https://www.douyin.com/video/${work.awemeId || normalizedAwemeId}`,
+      comments,
+      isDeepSampled: comments.length > 0,
+      capturedAt,
+    };
+    return {
+      ...merged,
+      interactionScore: this.interactionScore(merged.metrics),
+    };
+  }
+
+  async fetchAccountWorksPage(
+    account: IpMonitoredAccount,
+    options: {
+      cursor?: string;
+      count: number;
+      includeCommentsForTop?: number;
+    },
+    capturedAt = new Date().toISOString(),
+  ): Promise<IpAccountWorksPageResult> {
+    if (!account.secUserId) {
+      return { works: [], comments: [], hasMore: false, pageStateReliable: false };
+    }
+
+    const count = Math.max(1, Math.min(Number(options.count || 20), 50));
+    const response = await firstValueFrom(this.douyin.getUserPosts({
+      sec_user_id: account.secUserId,
+      max_cursor: options.cursor || 0,
+      count,
+      sort_type: 0,
+      filter_type: 0,
+    }));
+    this.assertBusinessSuccess(response, '账号作品');
+    const rawWorks = this.readArray(response, ['aweme_list', 'data', 'items', 'list']);
+    const works = rawWorks
+      .slice(0, count)
+      .map((raw: any, index: number) => this.normalizeWork(raw, account.id, index, capturedAt));
+    const commentTargetIds = new Set(
+      this.rankWorksForDeepCommentSampling(works, options.includeCommentsForTop ?? 3)
+        .map((work) => work.awemeId)
+        .filter(Boolean),
+    );
+    const enrichedWorks: IpAccountWorkSnapshot[] = [];
+    const comments: IpAccountCommentSample[] = [];
+
+    for (const work of works) {
+      if (!commentTargetIds.has(work.awemeId)) {
+        enrichedWorks.push(work);
+        continue;
+      }
+      const workComments = await this.fetchComments(work.awemeId, work.id, capturedAt);
+      comments.push(...workComments);
+      enrichedWorks.push({ ...work, isDeepSampled: workComments.length > 0, comments: workComments });
+    }
+
+    const cursor = this.readFirstText(response, [
+      'max_cursor',
+      'cursor',
+      'next_cursor',
+      'data.max_cursor',
+      'data.cursor',
+      'data.next_cursor',
+      'data.maxCursor',
+      'data.nextCursor',
+      'data.data.max_cursor',
+      'data.data.cursor',
+      'data.data.next_cursor',
+      'data.data.maxCursor',
+      'data.data.nextCursor',
+      'result.max_cursor',
+      'result.cursor',
+      'result.next_cursor',
+      'response.max_cursor',
+      'response.cursor',
+      'response.next_cursor',
+      'response.data.max_cursor',
+      'response.data.cursor',
+      'response.data.next_cursor',
+    ]);
+    const hasMoreValue = this.readFirstValue(response, [
+      'has_more',
+      'hasMore',
+      'has_next',
+      'hasNext',
+      'data.has_more',
+      'data.hasMore',
+      'data.has_next',
+      'data.hasNext',
+      'data.data.has_more',
+      'data.data.hasMore',
+      'data.data.has_next',
+      'data.data.hasNext',
+      'result.has_more',
+      'result.hasMore',
+      'result.has_next',
+      'result.hasNext',
+      'response.has_more',
+      'response.hasMore',
+      'response.has_next',
+      'response.hasNext',
+      'response.data.has_more',
+      'response.data.hasMore',
+    ]);
+    const hasReliableState = hasMoreValue !== undefined || !!cursor;
+    const hasMore = hasMoreValue === undefined
+      ? enrichedWorks.length >= count
+      : hasMoreValue === true || hasMoreValue === 1 || hasMoreValue === '1' || hasMoreValue === 'true';
+
+    return {
+      works: enrichedWorks,
+      comments,
+      cursor,
+      hasMore,
+      pageStateReliable: hasReliableState,
+    };
+  }
+
+  rejectBinding(binding: IpPublishWorkBinding): IpPublishWorkBinding {
+    return {
+      ...binding,
+      status: 'rejected',
+      updatedAt: new Date().toISOString(),
+    };
+  }
+
+  private async fetchProfile(account: IpMonitoredAccount): Promise<IpAccountProfileSnapshot> {
+    const fallback: IpAccountProfileSnapshot = {
+      nickname: account.displayName,
+      signature: '',
+    };
+
+    try {
+      const response = account.uniqueId
+        ? await firstValueFrom(this.douyin.getUserProfileByUniqueId(account.uniqueId))
+        : account.secUserId
+          ? await firstValueFrom(this.douyin.getUserProfile(account.secUserId))
+          : null;
+      this.assertBusinessSuccess(response, '账号主页');
+      const primaryProfile = this.normalizeProfile(response, fallback);
+      const resolvedSecUserId = account.secUserId || (primaryProfile as any).secUserId;
+
+      if (!account.uniqueId || !resolvedSecUserId) {
+        return primaryProfile;
+      }
+
+      try {
+        const detailResponse = await firstValueFrom(this.douyin.getUserProfile(resolvedSecUserId));
+        this.assertBusinessSuccess(detailResponse, '账号主页');
+        const detailProfile = this.normalizeProfile(detailResponse, primaryProfile);
+        return this.mergeProfileSnapshots(primaryProfile, detailProfile);
+      } catch {
+        return primaryProfile;
+      }
+    } catch (error: any) {
+      throw new Error(`无法识别抖音账号:${error?.message || '主页链接或账号 ID 无效'}。请确认链接可公开访问,或改用抖音号重试。`);
+    }
+  }
+
+  private currentSnapshotId(accountId: string): string {
+    return `snapshot_${accountId}_current`;
+  }
+
+  private normalizeProfile(response: unknown, fallback: IpAccountProfileSnapshot): IpAccountProfileSnapshot {
+    const raw = this.findProfileObject(response);
+    if (!raw) return fallback;
+    const avatarCandidates = this.avatarCandidates(raw);
+
+    return {
+      nickname: this.asText(raw.nickname || raw.name || fallback.nickname),
+      signature: this.asText(raw.signature || raw.desc || raw.short_id || fallback.signature),
+      avatarUrl: avatarCandidates[0] || fallback.avatarUrl,
+      avatarCandidates: this.mergeUrlCandidates(avatarCandidates, fallback.avatarCandidates || [], fallback.avatarUrl ? [fallback.avatarUrl] : []),
+      followerCount: this.pickCountValue([
+        raw.follower_count,
+        raw.followers_count,
+        raw.followerCount,
+        raw.fans_count,
+        raw.fansCount,
+        raw.public_fans_count,
+        raw.statistics?.follower_count,
+        raw.statistics?.fans_count,
+        raw.author_stats?.follower_count,
+        raw.author_stats?.fans_count,
+        raw.user_stats?.follower_count,
+        raw.user_stats?.fans_count,
+        raw.user_statistics?.follower_count,
+        raw.user_statistics?.fans_count,
+        raw.stats?.follower_count,
+        raw.stats?.fans_count,
+      ]),
+      followingCount: this.pickCountValue([
+        raw.following_count,
+        raw.followingCount,
+        raw.statistics?.following_count,
+        raw.author_stats?.following_count,
+        raw.user_stats?.following_count,
+        raw.user_statistics?.following_count,
+        raw.stats?.following_count,
+      ]),
+      totalFavorited: this.pickCountValue([
+        raw.total_favorited,
+        raw.totalFavorited,
+        raw.like_count,
+        raw.favoriting_count,
+        raw.liked_count,
+        raw.statistics?.total_favorited,
+        raw.statistics?.favoriting_count,
+        raw.author_stats?.total_favorited,
+        raw.user_stats?.total_favorited,
+        raw.user_statistics?.total_favorited,
+        raw.stats?.total_favorited,
+      ]),
+      awemeCount: this.pickCountValue([
+        raw.aweme_count,
+        raw.awemeCount,
+        raw.video_count,
+        raw.statistics?.aweme_count,
+        raw.statistics?.video_count,
+        raw.author_stats?.aweme_count,
+        raw.user_stats?.aweme_count,
+        raw.user_statistics?.aweme_count,
+        raw.stats?.aweme_count,
+      ]),
+      secUserId: this.asText(raw.sec_uid || raw.sec_user_id || raw.secUid || raw.secUserId),
+      uniqueId: this.asText(raw.unique_id || raw.uniqueId || raw.short_id),
+    } as IpAccountProfileSnapshot & { secUserId?: string; uniqueId?: string };
+  }
+
+  private mergeProfileSnapshots(
+    primary: IpAccountProfileSnapshot,
+    detail: IpAccountProfileSnapshot,
+  ): IpAccountProfileSnapshot {
+    const primaryMeta = primary as IpAccountProfileSnapshot & { secUserId?: string; uniqueId?: string };
+    const detailMeta = detail as IpAccountProfileSnapshot & { secUserId?: string; uniqueId?: string };
+    const avatarCandidates = this.mergeUrlCandidates(
+      detail.avatarCandidates || [],
+      primary.avatarCandidates || [],
+      detail.avatarUrl ? [detail.avatarUrl] : [],
+      primary.avatarUrl ? [primary.avatarUrl] : [],
+    );
+    return {
+      ...primary,
+      ...detail,
+      nickname: detail.nickname || primary.nickname,
+      signature: detail.signature || primary.signature,
+      avatarUrl: avatarCandidates[0] || detail.avatarUrl || primary.avatarUrl,
+      avatarCandidates,
+      followerCount: this.mergeCountValue(primary.followerCount, detail.followerCount),
+      followingCount: this.mergeCountValue(primary.followingCount, detail.followingCount),
+      totalFavorited: this.mergeCountValue(primary.totalFavorited, detail.totalFavorited),
+      awemeCount: this.mergeMaximumCountValue(primary.awemeCount, detail.awemeCount),
+      secUserId: detailMeta.secUserId || primaryMeta.secUserId,
+      uniqueId: detailMeta.uniqueId || primaryMeta.uniqueId,
+    } as IpAccountProfileSnapshot & { secUserId?: string; uniqueId?: string };
+  }
+
+  private avatarCandidates(raw: any): string[] {
+    return this.uniqueUrls([
+      raw?.avatar_larger?.url_list,
+      raw?.avatar_medium?.url_list,
+      raw?.avatar_thumb?.url_list,
+      raw?.avatar_168x168?.url_list,
+      raw?.avatar_url,
+      raw?.avatar,
+    ]);
+  }
+
+  private mergeCountValue(primary: number | undefined, detail: number | undefined): number | undefined {
+    if (detail === undefined || detail === null) return primary;
+    if (detail === 0 && primary !== undefined && primary > 0) return primary;
+    return detail;
+  }
+
+  private mergeMaximumCountValue(primary: number | undefined, detail: number | undefined): number | undefined {
+    if (primary === undefined || primary === null) return detail;
+    if (detail === undefined || detail === null) return primary;
+    return Math.max(primary, detail);
+  }
+
+  private mergeUrlCandidates(...groups: (string | undefined)[][]): string[] {
+    return this.uniqueUrls(groups);
+  }
+
+  private normalizeWork(raw: any, accountId: string, index: number, capturedAt: string): IpAccountWorkSnapshot {
+    const metrics: IpAccountWorkMetricSnapshot = {
+      playCount: this.asNumber(raw?.statistics?.play_count || raw?.statistics?.playCount || raw?.play_count),
+      likeCount: this.asNumber(raw?.statistics?.digg_count || raw?.statistics?.like_count || raw?.digg_count),
+      commentCount: this.asNumber(raw?.statistics?.comment_count || raw?.comment_count),
+      collectCount: this.asNumber(raw?.statistics?.collect_count || raw?.statistics?.collect_count || raw?.collect_count),
+      shareCount: this.asNumber(raw?.statistics?.share_count || raw?.share_count),
+    };
+    const awemeId = this.asText(raw?.aweme_id || raw?.awemeId || raw?.id || `work_${index + 1}`);
+    const desc = this.asText(raw?.desc || raw?.title || '');
+    const coverCandidateEntries = this.coverCandidateEntries(raw);
+    const coverCandidates = coverCandidateEntries.map((entry) => entry.url);
+    const coverCandidateSources = coverCandidateEntries.reduce<Record<string, IpAccountWorkImageSource>>((acc, entry) => {
+      acc[entry.url] = entry.source;
+      return acc;
+    }, {});
+    const transcript = this.extractTranscript(raw, capturedAt);
+    return {
+      id: `work_${accountId}_${awemeId}`,
+      accountId,
+      awemeId,
+      title: desc || `作品 ${index + 1}`,
+      desc,
+      coverUrl: coverCandidates[0],
+      coverCandidates,
+      coverSource: coverCandidateEntries[0]?.source,
+      coverCandidateSources,
+      publishTime: this.normalizePublishTime(raw?.create_time || raw?.createTime),
+      url: awemeId ? `https://www.douyin.com/video/${awemeId}` : undefined,
+      transcript: transcript || undefined,
+      metrics,
+      interactionScore: this.interactionScore(metrics),
+      isDeepSampled: false,
+      structure: this.inferStructure(desc),
+      comments: [],
+      capturedAt,
+    };
+  }
+
+  private extractTranscript(raw: any, capturedAt: string): IpAccountWorkSnapshot['transcript'] | undefined {
+    const candidates: Array<{ text: string; source: IpAccountWorkTranscriptSource }> = [];
+    const push = (value: unknown, source: IpAccountWorkTranscriptSource) => {
+      const text = this.asText(value).replace(/\s+/g, ' ').trim();
+      if (text.length >= 20 && !/^https?:\/\//i.test(text)) candidates.push({ text, source });
+    };
+    const pushFromArray = (value: unknown, source: IpAccountWorkTranscriptSource) => {
+      if (!Array.isArray(value)) return;
+      value.forEach((item: any) => push(item?.text || item?.content || item?.caption || item?.words || item?.sentence, source));
+    };
+
+    push(raw?.asr_text || raw?.asrText || raw?.video?.asr_text || raw?.video?.asrText, 'asr');
+    push(raw?.speech_text || raw?.speechText || raw?.voice_text || raw?.voiceText, 'asr');
+    push(raw?.video?.speech_text || raw?.video?.speechText || raw?.video?.voice_text || raw?.video?.voiceText, 'asr');
+    push(raw?.caption || raw?.video?.caption, 'caption');
+    push(raw?.subtitle || raw?.video?.subtitle, 'subtitle');
+    pushFromArray(raw?.caption_infos || raw?.video?.caption_infos || raw?.video?.cla_info?.caption_infos, 'caption');
+    pushFromArray(raw?.subtitle_infos || raw?.video?.subtitle_infos, 'subtitle');
+    this.collectTranscriptLikeText(raw, candidates, 0);
+
+    const desc = this.asText(raw?.desc || raw?.title || '');
+    const unique = Array.from(new Map(candidates
+      .filter((item) => item.text !== desc)
+      .map((item) => [item.text, item])).values())
+      .sort((left, right) => right.text.length - left.text.length);
+    const best = unique[0];
+    if (!best) return undefined;
+    return {
+      text: best.text.slice(0, 8000),
+      source: best.source,
+      confidence: best.source === 'asr' || best.source === 'caption' || best.source === 'subtitle' ? 'high' : 'medium',
+      capturedAt,
+    };
+  }
+
+  private collectTranscriptLikeText(
+    value: unknown,
+    candidates: Array<{ text: string; source: IpAccountWorkTranscriptSource }>,
+    depth: number,
+  ): void {
+    if (!value || depth > 4) return;
+    if (Array.isArray(value)) {
+      value.forEach((item) => this.collectTranscriptLikeText(item, candidates, depth + 1));
+      return;
+    }
+    if (typeof value !== 'object') return;
+    for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
+      const lower = key.toLowerCase();
+      if (/transcript|subtitle|caption|asr|speech|voice_text|ocr_text/.test(lower) && typeof raw === 'string') {
+        const source: IpAccountWorkTranscriptSource = /asr|speech|voice/.test(lower)
+          ? 'asr'
+          : /subtitle/.test(lower)
+            ? 'subtitle'
+            : /caption/.test(lower)
+              ? 'caption'
+              : 'unknown';
+        const text = raw.replace(/\s+/g, ' ').trim();
+        if (text.length >= 20 && !/^https?:\/\//i.test(text)) candidates.push({ text, source });
+      }
+      this.collectTranscriptLikeText(raw, candidates, depth + 1);
+    }
+  }
+
+  async fetchWorkCommentsForAnalysis(
+    work: IpAccountWorkSnapshot,
+    capturedAt = new Date().toISOString(),
+  ): Promise<IpAccountCommentSample[]> {
+    if (!work.awemeId) return [];
+    return this.fetchComments(work.awemeId, work.id, capturedAt);
+  }
+
+  private async fetchComments(awemeId: string, workId: string, capturedAt: string): Promise<IpAccountCommentSample[]> {
+    const response = await firstValueFrom(this.douyin.getVideoComments({ aweme_id: awemeId, count: 50 }, { optional: true }));
+    if (response) this.assertBusinessSuccess(response, '作品评论');
+    const comments = this.readArray(response, ['comments', 'data', 'items', 'list']);
+    return comments.slice(0, 50)
+      .map((raw: any, index: number) => ({
+        id: `${workId}_comment_${index + 1}`,
+        workId,
+        text: this.asText(raw?.text || raw?.content),
+        likeCount: this.asNumber(raw?.digg_count || raw?.like_count),
+        replyCount: this.asNumber(raw?.reply_comment_total || raw?.reply_count),
+        authorName: this.asText(raw?.user?.nickname || raw?.user_nickname),
+        capturedAt,
+      }))
+      .filter((comment) => comment.text);
+  }
+
+  private inferStructure(text: string): IpAccountWorkSnapshot['structure'] {
+    const clean = this.asText(text);
+    return {
+      hook: this.firstSentence(clean) || '开头钩子待识别',
+      topic: clean.length > 18 ? clean.slice(0, 18) : clean || '主题待识别',
+      style: /为什么|怎么|如何|避坑|别再|千万/.test(clean) ? '问题/避坑表达' : '观点表达',
+      cta: /评论|留言|关注|收藏/.test(clean) ? '已有互动引导' : '互动引导待加强',
+    };
+  }
+
+  private readArray(value: any, keys: string[], depth = 0): any[] {
+    if (Array.isArray(value)) return value;
+    if (!value || typeof value !== 'object' || depth > 6) return [];
+    for (const key of keys) {
+      const candidate = value?.[key];
+      if (Array.isArray(candidate)) return candidate;
+    }
+    for (const key of ['data', 'result', 'response']) {
+      const nested = this.readArray(value?.[key], keys, depth + 1);
+      if (nested.length) return nested;
+    }
+    return [];
+  }
+
+  private readFirstText(value: any, paths: string[]): string | undefined {
+    for (const path of paths) {
+      const found = this.readPath(value, path);
+      const text = this.asText(found);
+      if (text) return text;
+    }
+    return undefined;
+  }
+
+  private readFirstValue(value: any, paths: string[]): any {
+    for (const path of paths) {
+      const found = this.readPath(value, path);
+      if (found !== undefined && found !== null && found !== '') return found;
+    }
+    return undefined;
+  }
+
+  private readPath(value: any, path: string): any {
+    value = this.parseJsonPayload(value);
+    if (!value || typeof value !== 'object') return undefined;
+    return path.split('.').reduce((current: any, key) => {
+      if (current === undefined || current === null) return undefined;
+      current = this.parseJsonPayload(current);
+      return current?.[key];
+    }, value);
+  }
+
+  private findProfileObject(value: any, depth = 0): any {
+    value = this.parseJsonPayload(value);
+    if (!value || typeof value !== 'object' || depth > 6) return null;
+    if (value.nickname || value.sec_uid || value.sec_user_id) return value;
+    for (const key of ['user_info', 'user', 'author', 'data', 'result', 'response']) {
+      const found = this.findProfileObject(value?.[key], depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+
+  private findWorkObject(value: any, awemeId?: string, depth = 0): any {
+    value = this.parseJsonPayload(value);
+    if (!value || typeof value !== 'object' || depth > 7) return null;
+    if (Array.isArray(value)) {
+      for (const item of value) {
+        const found = this.findWorkObject(item, awemeId, depth + 1);
+        if (found) return found;
+      }
+      return null;
+    }
+
+    const valueAwemeId = this.asText(value.aweme_id || value.awemeId || value.item_id || value.itemId || value.id);
+    const hasWorkShape = !!valueAwemeId
+      && (!awemeId || valueAwemeId === awemeId)
+      && !!(value.desc || value.title || value.statistics || value.video || value.create_time || value.createTime);
+    if (hasWorkShape) return value;
+
+    for (const key of [
+      'aweme_detail',
+      'aweme_info',
+      'aweme',
+      'item',
+      'video',
+      'data',
+      'result',
+      'response',
+    ]) {
+      const found = this.findWorkObject(value?.[key], awemeId, depth + 1);
+      if (found) return found;
+    }
+
+    for (const key of ['aweme_details', 'aweme_list', 'items', 'list']) {
+      const found = this.findWorkObject(value?.[key], awemeId, depth + 1);
+      if (found) return found;
+    }
+
+    return null;
+  }
+
+  private parseJsonPayload(value: any): any {
+    if (typeof value !== 'string') return value;
+    const trimmed = value.trim();
+    if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value;
+    try {
+      return JSON.parse(trimmed);
+    } catch {
+      return value;
+    }
+  }
+
+  private assertBusinessSuccess(value: any, label: string): void {
+    const status = this.findBusinessStatus(value);
+    if (!status || status.code === 0) return;
+    throw new Error(`${label}接口返回错误:${status.message || `状态码 ${status.code}`}`);
+  }
+
+  private findBusinessStatus(value: any, depth = 0): { code: number; message: string } | null {
+    if (!value || typeof value !== 'object' || depth > 6) return null;
+    if (value.status_code !== undefined && value.status_code !== null) {
+      return {
+        code: Number(value.status_code),
+        message: this.asText(value.status_msg || value.message || value.msg),
+      };
+    }
+    for (const key of ['data', 'result', 'response']) {
+      const found = this.findBusinessStatus(value?.[key], depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+
+  private isUniqueId(value: string): boolean {
+    return /^[a-zA-Z0-9_.-]{2,64}$/.test(value);
+  }
+
+  private extractAwemeId(url: string): string | undefined {
+    const direct = String(url || '').match(/\/(?:video|note)\/(\d+)/);
+    if (direct?.[1]) return direct[1];
+    const modal = String(url || '').match(/[?&]modal_id=(\d+)/);
+    if (modal?.[1]) return modal[1];
+    const raw = String(url || '').trim();
+    return /^\d{8,}$/.test(raw) ? raw : undefined;
+  }
+
+  private similarity(a: string, b: string): number {
+    const left = this.tokenSet(a);
+    const right = this.tokenSet(b);
+    if (!left.size || !right.size) return 0;
+    let hit = 0;
+    for (const token of left) {
+      if (right.has(token)) hit += 1;
+    }
+    return hit / Math.max(left.size, right.size);
+  }
+
+  private tokenSet(value: string): Set<string> {
+    const normalized = this.asText(value).replace(/[^\p{L}\p{N}]+/gu, ' ');
+    return new Set(normalized.split(/\s+/).map((item) => item.trim()).filter((item) => item.length >= 2));
+  }
+
+  private firstSentence(text: string): string {
+    return this.asText(text).split(/[。!?!?;;\n]/).map((item) => item.trim()).filter(Boolean)[0] || '';
+  }
+
+  private coverCandidateEntries(raw: any): Array<{ url: string; source: IpAccountWorkImageSource }> {
+    const result: Array<{ url: string; source: IpAccountWorkImageSource }> = [];
+    const seen = new Set<string>();
+    const add = (value: any, source: IpAccountWorkImageSource) => {
+      const visit = (entry: any) => {
+        if (!entry) return;
+        if (typeof entry === 'string') {
+          const url = entry.trim();
+          if (/^https?:\/\//i.test(url) && !seen.has(url)) {
+            seen.add(url);
+            result.push({ url, source });
+          }
+          return;
+        }
+        if (Array.isArray(entry)) {
+          entry.forEach(visit);
+          return;
+        }
+        if (Array.isArray(entry?.url_list)) visit(entry.url_list);
+      };
+      visit(value);
+    };
+
+    add(raw?.video?.cover?.url_list, 'platform_cover');
+    add(raw?.cover?.url_list, 'platform_cover');
+    add(raw?.cover_url, 'platform_cover');
+    add(raw?.video?.origin_cover?.url_list, 'origin_preview');
+    add(raw?.video?.dynamic_cover?.url_list, 'dynamic_preview');
+    add(raw?.video?.animated_cover?.url_list, 'dynamic_preview');
+    add(raw?.images?.map((image: any) => image?.url_list || image?.download_url_list), 'image_group');
+    add(raw?.image_infos?.map((image: any) => image?.label_large?.url_list || image?.label_thumb?.url_list), 'image_group');
+
+    return result;
+  }
+
+  private uniqueUrls(values: any[]): string[] {
+    const result: string[] = [];
+    const visit = (value: any) => {
+      if (!value) return;
+      if (typeof value === 'string') {
+        const url = value.trim();
+        if (/^https?:\/\//i.test(url) && !result.includes(url)) result.push(url);
+        return;
+      }
+      if (Array.isArray(value)) {
+        value.forEach(visit);
+        return;
+      }
+      if (Array.isArray(value?.url_list)) visit(value.url_list);
+    };
+    values.forEach(visit);
+    return result;
+  }
+
+  private normalizePublishTime(value: unknown): string | undefined {
+    const num = Number(value || 0);
+    if (!num) return undefined;
+    return new Date(num > 10_000_000_000 ? num : num * 1000).toISOString();
+  }
+
+  private asNumber(value: unknown): number | undefined {
+    const num = Number(value || 0);
+    return Number.isFinite(num) ? num : undefined;
+  }
+
+  private pickCountValue(values: unknown[]): number | undefined {
+    for (const value of values) {
+      const parsed = this.parseCountValue(value);
+      if (parsed !== undefined) return parsed;
+    }
+    return undefined;
+  }
+
+  private parseCountValue(value: unknown): number | undefined {
+    if (value === undefined || value === null || value === '') return undefined;
+    if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
+    if (typeof value === 'string') {
+      const compact = value.trim().replace(/,/g, '').replace(/\s+/g, '');
+      if (!compact) return undefined;
+      const unitMatch = compact.match(/^([\d.]+)(亿|万|[kKmMwW])$/);
+      if (unitMatch) {
+        const base = Number(unitMatch[1]);
+        if (!Number.isFinite(base)) return undefined;
+        const unit = unitMatch[2].toLowerCase();
+        const multiplier = unit === '亿'
+          ? 100_000_000
+          : unit === '万' || unit === 'w'
+            ? 10_000
+            : unit === 'm'
+              ? 1_000_000
+              : 1_000;
+        return Math.round(base * multiplier);
+      }
+      const direct = Number(compact);
+      return Number.isFinite(direct) ? direct : undefined;
+    }
+    if (value && typeof value === 'object') {
+      for (const key of ['count', 'value', 'total', 'num']) {
+        const parsed = this.parseCountValue((value as Record<string, unknown>)[key]);
+        if (parsed !== undefined) return parsed;
+      }
+    }
+    return undefined;
+  }
+
+  private asText(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+}

+ 143 - 0
src/app/services/ip-account-reference-work-analysis.service.spec.ts

@@ -0,0 +1,143 @@
+import { TestBed } from '@angular/core/testing';
+import { IpAccountSnapshot, IpAccountWorkSnapshot, IpMonitoredAccount } from '../models/ip-operator.model';
+import { IpAccountReferenceWorkAnalysisService } from './ip-account-reference-work-analysis.service';
+import { IpOperatorService } from './ip-operator.service';
+import { IpAccountWorkMediaAnalysisService } from './ip-account-work-media-analysis.service';
+
+describe('IpAccountReferenceWorkAnalysisService', () => {
+  let service: IpAccountReferenceWorkAnalysisService;
+  let referenceAnalysis: { analyze: ReturnType<typeof vi.fn> };
+  let ipOperator: {
+    saveAccountSnapshot: ReturnType<typeof vi.fn>;
+    saveAccountWorkAnalysisRun: ReturnType<typeof vi.fn>;
+  };
+
+  const account: IpMonitoredAccount = {
+    id: 'account-1',
+    userId: 'user-1',
+    platform: 'douyin',
+    role: 'owned',
+    displayName: 'FredTalk',
+    accountId: '917997605',
+    uniqueId: '917997605',
+    secUserId: 'sec-1',
+    homepageUrl: '',
+    enabled: true,
+    accountStatus: 'active',
+    lastRefreshStatus: 'completed',
+    createdAt: '2026-07-05T00:00:00.000Z',
+    updatedAt: '2026-07-05T00:00:00.000Z',
+  };
+
+  const work: IpAccountWorkSnapshot = {
+    id: 'work-1',
+    accountId: 'account-1',
+    awemeId: '7636915997076770089',
+    title: '新手小白安装 Skills',
+    desc: '新手小白安装 Skills 就行够了',
+    url: '',
+    coverUrl: '',
+    publishTime: '2026-05-07T06:55:00.000Z',
+    metrics: {
+      playCount: 0,
+      likeCount: 58850,
+      commentCount: 1036,
+      collectCount: 61773,
+      shareCount: 9581,
+    },
+    interactionScore: 121680,
+    comments: [{
+      id: 'comment-1',
+      workId: 'work-1',
+      text: '视频里 skill 名字发给我',
+      likeCount: 12,
+      replyCount: 1,
+      authorName: '用户A',
+      capturedAt: '2026-07-05T00:00:00.000Z',
+    }],
+    isDeepSampled: true,
+    capturedAt: '2026-07-05T00:00:00.000Z',
+  };
+
+  const snapshot: IpAccountSnapshot = {
+    id: 'snapshot_account-1_current',
+    accountId: 'account-1',
+    profile: {
+      nickname: 'FredTalk',
+      signature: '每天学习新的 AI 实操能力',
+      followerCount: 31811,
+      followingCount: 345,
+      awemeCount: 53,
+      totalFavorited: 175143,
+    },
+    works: [work],
+    dataMode: 'data_diagnosis',
+    evidenceItemIds: [],
+    capturedAt: '2026-07-05T00:00:00.000Z',
+    warnings: [],
+  };
+
+  beforeEach(() => {
+    referenceAnalysis = {
+      analyze: vi.fn(),
+    };
+    ipOperator = {
+      saveAccountSnapshot: vi.fn((value: IpAccountSnapshot) => value),
+      saveAccountWorkAnalysisRun: vi.fn((value) => value),
+    };
+
+    TestBed.configureTestingModule({
+      providers: [
+        IpAccountReferenceWorkAnalysisService,
+        { provide: IpAccountWorkMediaAnalysisService, useValue: referenceAnalysis },
+        { provide: IpOperatorService, useValue: ipOperator },
+      ],
+    });
+    service = TestBed.inject(IpAccountReferenceWorkAnalysisService);
+  });
+
+  it('saves a transcript-backed llm work report for account strategy aggregation', async () => {
+    referenceAnalysis.analyze.mockResolvedValue({
+      transcript: '这是完整逐字稿,讲新手如何选择和安装 AI Skills。',
+      analysisText: '【主题定位】\n帮助新手解决 AI 工具选择困难。\n\n【用户反馈信号】\n评论集中在 skill 名称和安装步骤。',
+      source: 'douyin_transcript',
+      warnings: [],
+    });
+
+    const result = await service.analyzeAccountWork({ account, snapshot, work, temporary: false });
+
+    expect(referenceAnalysis.analyze).toHaveBeenCalledWith(expect.objectContaining({
+      awemeId: work.awemeId,
+      videoSnapshot: expect.objectContaining({ awemeId: work.awemeId }),
+      commentSamples: [expect.objectContaining({ id: 'comment-1', text: '视频里 skill 名字发给我' })],
+    }));
+    expect(result.run.id).toBe('ip_reference_analysis_snapshot_account-1_current_work-1');
+    expect(result.run.status).toBe('completed');
+    expect(result.report?.sourceMode).toBe('llm');
+    expect(result.report?.reports.length).toBeGreaterThan(0);
+    expect(result.report?.supportingCommentSamples?.[0].text).toContain('skill 名字');
+    expect(ipOperator.saveAccountSnapshot).toHaveBeenCalledWith(expect.objectContaining({
+      works: [expect.objectContaining({
+        transcript: expect.objectContaining({ source: 'asr' }),
+      })],
+    }), { temporary: false });
+    expect(ipOperator.saveAccountWorkAnalysisRun).toHaveBeenCalledWith(expect.objectContaining({
+      id: 'ip_reference_analysis_snapshot_account-1_current_work-1',
+      status: 'completed',
+    }), { temporary: false });
+  });
+
+  it('saves a failed run without inventing a report when analysis fails', async () => {
+    referenceAnalysis.analyze.mockRejectedValue(new Error('上游转写失败'));
+
+    const result = await service.analyzeAccountWork({ account, snapshot, work, temporary: false });
+
+    expect(result.run.status).toBe('failed');
+    expect(result.report).toBeUndefined();
+    expect(result.run.failureReason).toContain('上游转写失败');
+    expect(ipOperator.saveAccountWorkAnalysisRun).toHaveBeenLastCalledWith(expect.objectContaining({
+      id: 'ip_reference_analysis_snapshot_account-1_current_work-1',
+      status: 'failed',
+    }), { temporary: false });
+  });
+});

+ 329 - 0
src/app/services/ip-account-reference-work-analysis.service.ts

@@ -0,0 +1,329 @@
+import { Injectable } from '@angular/core';
+import {
+  IpAccountSnapshot,
+  IpAccountStrategyDiagnosticEvent,
+  IpAccountWorkAnalysisRun,
+  IpAccountWorkReportBlock,
+  IpAccountWorkSnapshot,
+  IpAccountWorkStrategyReport,
+  IpAccountWorkTranscriptSource,
+  IpMonitoredAccount,
+} from '../models/ip-operator.model';
+import {
+  IpAccountWorkMediaAnalysisSource,
+  IpAccountWorkMediaAnalysisResult,
+  IpAccountWorkMediaAnalysisService,
+} from './ip-account-work-media-analysis.service';
+import { IpOperatorService } from './ip-operator.service';
+
+export interface AnalyzeAccountReferenceWorkInput {
+  account: IpMonitoredAccount;
+  snapshot: IpAccountSnapshot;
+  work: IpAccountWorkSnapshot;
+  temporary?: boolean;
+}
+
+export interface PersistReferenceAnalysisResultInput extends AnalyzeAccountReferenceWorkInput {
+  result: IpAccountWorkMediaAnalysisResult;
+}
+
+export interface AnalyzeAccountReferenceWorkResult {
+  snapshot: IpAccountSnapshot;
+  work: IpAccountWorkSnapshot;
+  run: IpAccountWorkAnalysisRun;
+  report?: IpAccountWorkStrategyReport;
+}
+
+interface ParsedAnalysisSection {
+  title: string;
+  body: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountReferenceWorkAnalysisService {
+  constructor(
+    private readonly referenceAnalysis: IpAccountWorkMediaAnalysisService,
+    private readonly ipOperator: IpOperatorService,
+  ) {}
+
+  runId(snapshotId: string, workId: string): string {
+    return `ip_reference_analysis_${snapshotId}_${workId}`.replace(/[^\w.-]+/g, '_');
+  }
+
+  async analyzeAccountWork(input: AnalyzeAccountReferenceWorkInput): Promise<AnalyzeAccountReferenceWorkResult> {
+    const temporary = !!input.temporary;
+    const now = new Date().toISOString();
+    const startedRun = this.saveRun({
+      id: this.runId(input.snapshot.id, input.work.id),
+      accountId: input.account.id,
+      snapshotId: input.snapshot.id,
+      workId: input.work.id,
+      awemeId: input.work.awemeId,
+      status: 'analyzing',
+      diagnostics: [
+        this.diagnostic('llm_request', 'started', '正在提取文字稿并分析作品', now),
+      ],
+      createdAt: now,
+      updatedAt: now,
+    }, temporary);
+
+    try {
+      const result = await this.referenceAnalysis.analyze({
+        awemeId: input.work.awemeId,
+        analysisId: `ip-v1-${input.snapshot.id}-${input.work.id}`,
+        transcript: this.trustedTranscriptText(input.work),
+        videoSnapshot: {
+          awemeId: input.work.awemeId,
+          desc: input.work.desc || input.work.title,
+          diggCount: input.work.metrics.likeCount,
+          commentCount: input.work.metrics.commentCount,
+          shareCount: input.work.metrics.shareCount,
+          playCount: input.work.metrics.playCount,
+          coverUrl: input.work.coverUrl,
+          createTime: input.work.publishTime,
+        },
+        commentSamples: (input.work.comments || []).map((comment) => ({
+          id: comment.id,
+          text: comment.text,
+          likeCount: comment.likeCount,
+          replyCount: comment.replyCount,
+          authorName: comment.authorName,
+        })),
+      });
+      return this.persistReferenceAnalysisResult({
+        ...input,
+        result,
+        temporary,
+        createdAt: startedRun.createdAt,
+      } as PersistReferenceAnalysisResultInput & { createdAt: string });
+    } catch (error: any) {
+      const failedRun = this.saveRun({
+        ...startedRun,
+        status: 'failed',
+        failureReason: error?.message || '作品分析失败',
+        diagnostics: [
+          ...startedRun.diagnostics,
+          this.diagnostic('llm_fallback', 'failed', '作品分析失败', new Date().toISOString(), error?.message || String(error)),
+        ],
+        updatedAt: new Date().toISOString(),
+      }, temporary);
+      return {
+        snapshot: input.snapshot,
+        work: input.work,
+        run: failedRun,
+      };
+    }
+  }
+
+  async persistReferenceAnalysisResult(
+    input: PersistReferenceAnalysisResultInput & { createdAt?: string },
+  ): Promise<AnalyzeAccountReferenceWorkResult> {
+    const temporary = !!input.temporary;
+    const snapshot = this.saveTranscript(input.snapshot, input.work.id, input.result, temporary);
+    const work = snapshot.works.find((item) => item.id === input.work.id) || input.work;
+    const run = this.saveRun(
+      this.buildCompletedRun(input.account, snapshot, work, input.result, input.createdAt || new Date().toISOString()),
+      temporary,
+    );
+    return {
+      snapshot,
+      work,
+      run,
+      report: run.report,
+    };
+  }
+
+  private buildCompletedRun(
+    account: IpMonitoredAccount,
+    snapshot: IpAccountSnapshot,
+    work: IpAccountWorkSnapshot,
+    result: IpAccountWorkMediaAnalysisResult,
+    createdAt: string,
+  ): IpAccountWorkAnalysisRun {
+    const now = new Date().toISOString();
+    const report = this.buildReport(work, result, now);
+    return {
+      id: this.runId(snapshot.id, work.id),
+      accountId: account.id,
+      snapshotId: snapshot.id,
+      workId: work.id,
+      awemeId: work.awemeId,
+      status: 'completed',
+      transcriptSource: this.transcriptSourceForAnalysis(result.source),
+      transcriptJob: {
+        id: `ip_reference_transcript_${work.id}`.replace(/[^\w.-]+/g, '_'),
+        awemeId: work.awemeId,
+        status: 'completed',
+        text: result.transcript,
+        warnings: result.warnings,
+        updatedAt: now,
+      },
+      report,
+      diagnostics: [
+        this.diagnostic(
+          'llm_response',
+          result.warnings.length ? 'fallback' : 'success',
+          '作品分析报告已生成',
+          now,
+          `${result.source} / 文字稿 ${result.transcript.length} 字 / 报告块 ${report.reports.length} 个`,
+        ),
+      ],
+      createdAt,
+      updatedAt: now,
+    };
+  }
+
+  private buildReport(
+    work: IpAccountWorkSnapshot,
+    result: IpAccountWorkMediaAnalysisResult,
+    now: string,
+  ): IpAccountWorkStrategyReport {
+    const sections = this.sectionsFromText(result.analysisText);
+    return {
+      id: `ip_reference_report_${work.id}`.replace(/[^\w.-]+/g, '_'),
+      workId: work.id,
+      sourceEvidenceId: `work_evidence_${work.id}`,
+      title: this.compact(work.title || work.desc || '作品分析', 80),
+      sourceMode: 'llm',
+      reports: this.reportBlocksFromSections(work, sections),
+      supportingCommentEvidenceIds: (work.comments || []).slice(0, 12).map((comment) => comment.id),
+      supportingCommentSamples: this.rankedCommentSamples(work),
+      createdAt: now,
+    };
+  }
+
+  private sectionsFromText(value: string): ParsedAnalysisSection[] {
+    const text = String(value || '').trim();
+    if (!text) return [{ title: '参考视频分析', body: '分析完成' }];
+    const headingPattern = /【([^】]+)】/g;
+    const matches = [...text.matchAll(headingPattern)];
+    if (!matches.length) return [{ title: '参考视频分析', body: this.cleanAnalysisDisplayText(text) }];
+    return matches
+      .map((match, index) => {
+        const start = (match.index || 0) + match[0].length;
+        const end = index + 1 < matches.length ? matches[index + 1].index || text.length : text.length;
+        return {
+          title: match[1].trim(),
+          body: this.cleanAnalysisDisplayText(text.slice(start, end)),
+        };
+      })
+      .filter((item) => item.title && item.body);
+  }
+
+  private cleanAnalysisDisplayText(value: string): string {
+    return String(value || '')
+      .replace(/\*\*([^*]+)\*\*/g, '$1')
+      .replace(/^\s*[-*]\s+/gm, '')
+      .replace(/([::])\s+/g, '$1')
+      .replace(/[ \t]+\n/g, '\n')
+      .replace(/\n{3,}/g, '\n\n')
+      .trim();
+  }
+
+  private reportBlocksFromSections(
+    work: IpAccountWorkSnapshot,
+    sections: ParsedAnalysisSection[],
+  ): IpAccountWorkReportBlock[] {
+    const usable = sections.length ? sections : [{ title: '参考视频分析', body: '分析完成' }];
+    return usable.map((section, index) => ({
+      id: `ip_reference_${work.id}_${index + 1}`.replace(/[^\w.-]+/g, '_'),
+      kind: index === 0 ? 'structure' : 'operation',
+      headline: this.compact(section.title, 48),
+      summary: this.compact(section.body, 180),
+      evidenceSignals: section.body
+        .split(/\n+/)
+        .map((item) => item.replace(/^[-*]\s*/, '').trim())
+        .filter(Boolean)
+        .slice(0, 5),
+      strategyJudgment: this.compact(section.body, 240),
+      supportingEvidenceIds: [`work_evidence_${work.id}`],
+      confidence: 'medium',
+      gaps: [],
+    }));
+  }
+
+  private saveTranscript(
+    snapshot: IpAccountSnapshot,
+    workId: string,
+    result: IpAccountWorkMediaAnalysisResult,
+    temporary: boolean,
+  ): IpAccountSnapshot {
+    const transcript = String(result.transcript || '').trim();
+    if (!transcript) return snapshot;
+    const now = new Date().toISOString();
+    const next: IpAccountSnapshot = {
+      ...snapshot,
+      works: snapshot.works.map((work) => work.id === workId
+        ? {
+            ...work,
+            transcript: {
+              text: transcript,
+              source: this.transcriptSourceForAnalysis(result.source),
+              confidence: result.source === 'transcript_text' ? 'medium' : 'high',
+              capturedAt: work.transcript?.capturedAt || now,
+            },
+          }
+        : work),
+    };
+    return this.ipOperator.saveAccountSnapshot(next, { temporary });
+  }
+
+  private transcriptSourceForAnalysis(source: IpAccountWorkMediaAnalysisSource): IpAccountWorkTranscriptSource {
+    if (source === 'transcript_text') return 'manual';
+    if (source === 'gemini_video' || source === 'gemini_audio' || source === 'whisper_text' || source === 'douyin_transcript') return 'asr';
+    return 'unknown';
+  }
+
+  private trustedTranscriptText(work: IpAccountWorkSnapshot): string {
+    const text = String(work.transcript?.text || '').trim();
+    if (!text || text.length < 20) return '';
+    const desc = String(work.desc || work.title || '').trim();
+    if (desc && text === desc) return '';
+    return text;
+  }
+
+  private rankedCommentSamples(work: IpAccountWorkSnapshot): NonNullable<IpAccountWorkStrategyReport['supportingCommentSamples']> {
+    return [...(work.comments || [])]
+      .filter((comment) => String(comment.text || '').trim())
+      .sort((left, right) => this.commentScore(right) - this.commentScore(left))
+      .slice(0, 12)
+      .map((comment) => ({
+        id: comment.id,
+        text: comment.text,
+        likeCount: comment.likeCount,
+        replyCount: comment.replyCount,
+        authorName: comment.authorName,
+      }));
+  }
+
+  private commentScore(comment: NonNullable<IpAccountWorkSnapshot['comments']>[number]): number {
+    const text = String(comment.text || '').trim();
+    return Number(comment.likeCount || 0) + Number(comment.replyCount || 0) * 2 + Math.min(text.length, 120) / 20;
+  }
+
+  private saveRun(run: IpAccountWorkAnalysisRun, temporary: boolean): IpAccountWorkAnalysisRun {
+    return this.ipOperator.saveAccountWorkAnalysisRun(run, { temporary });
+  }
+
+  private diagnostic(
+    stage: IpAccountStrategyDiagnosticEvent['stage'],
+    status: IpAccountStrategyDiagnosticEvent['status'],
+    message: string,
+    createdAt: string,
+    detail?: string,
+  ): IpAccountStrategyDiagnosticEvent {
+    return {
+      id: `ip_reference_diag_${createdAt}_${stage}`.replace(/[^\w.-]+/g, '_'),
+      stage,
+      status,
+      message,
+      detail,
+      createdAt,
+    };
+  }
+
+  private compact(value: string, max: number): string {
+    const normalized = String(value || '').replace(/\s+/g, ' ').trim();
+    return normalized.length > max ? `${normalized.slice(0, Math.max(0, max - 1))}…` : normalized;
+  }
+}

+ 947 - 0
src/app/services/ip-account-strategy-orchestrator.service.spec.ts

@@ -0,0 +1,947 @@
+import { TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import { vi } from 'vitest';
+import {
+  IpAccountSnapshot,
+  IpAccountWorkStrategyReport,
+  IpCommentPainInsight,
+  IpContentDirection,
+  IpMonitoredAccount,
+  IpPositioningProposal,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LocalAccountDiagnosisResult } from './ip-account-diagnosis.service';
+import { IpAccountStrategyOrchestratorService } from './ip-account-strategy-orchestrator.service';
+import { LlmService } from './llm.service';
+
+describe('IpAccountStrategyOrchestratorService', () => {
+  let service: IpAccountStrategyOrchestratorService;
+  let llm: { askWithSystem: ReturnType<typeof vi.fn> };
+
+  beforeEach(() => {
+    llm = { askWithSystem: vi.fn().mockReturnValue(of(JSON.stringify(strategyJsonFixture()))) };
+    TestBed.configureTestingModule({
+      providers: [
+        IpAccountStrategyOrchestratorService,
+        { provide: LlmService, useValue: llm },
+      ],
+    });
+    service = TestBed.inject(IpAccountStrategyOrchestratorService);
+  });
+
+  it('builds bounded strategy context from account snapshot, comments and benchmark evidence', () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [{
+        id: 'evidence-1',
+        platform: 'douyin',
+        kind: 'competitor_post',
+        bucket: 'patterns',
+        title: '对标爆款:先给判断再讲步骤',
+        rawText: '高互动内容用强判断开头,再拆解可执行步骤。',
+        summary: '强判断开头降低理解门槛,后半段给可执行步骤。',
+        tags: ['强判断', '步骤拆解'],
+        relatedTopicIds: [],
+        relatedScriptIds: [],
+        evidenceRefs: [],
+        selectedForGeneration: true,
+        confidence: 'high',
+        createdAt: now(),
+        updatedAt: now(),
+      }],
+      painInsights: [painInsightFixture()],
+    });
+
+    expect(context.accountName).toBe('FredTalk');
+    expect(context.topWorks.length).toBe(1);
+    expect(context.commentSignals.join('\n')).toContain('不知道该先用哪个');
+    expect(context.benchmarkSignals.join('\n')).toContain('先给判断再讲步骤');
+    expect(context.painSignals.join('\n')).toContain('选择困难');
+    expect(context.rawTextBudget).toBeLessThanOrEqual(6000);
+    expect(context.workEvidenceBundles.length).toBe(1);
+    expect(context.workEvidenceBundles[0].workId).toBe('work-1');
+    expect(context.workEvidenceBundles[0].transcriptText).toContain('先判断任务');
+    expect(context.workEvidenceBundles[0].commentEvidenceRefs.length).toBe(1);
+    expect(context.evidenceRefs.filter((item) => item.sourceType === 'owned_comment').every((item) => item.analyzed === false)).toBe(true);
+    expect(context.evidenceRefs.every((item) => item.stage === 'raw_context')).toBe(true);
+  });
+
+  it('enforces the 6000 character context budget for large accounts and keeps only included evidence refs', () => {
+    const snapshot = snapshotFixture();
+    snapshot.profile.signature = '面向新手的超长账号简介'.repeat(80);
+    snapshot.works = Array.from({ length: 12 }, (_, workIndex) => ({
+      ...snapshot.works[0],
+      id: `work-${workIndex + 1}`,
+      awemeId: `aweme-${workIndex + 1}`,
+      title: `高互动长作品 ${workIndex + 1}:${'从误区到执行步骤'.repeat(20)}`,
+      interactionScore: 100000 - workIndex,
+      comments: Array.from({ length: 30 }, (__, commentIndex) => ({
+        ...snapshot.works[0].comments[0],
+        id: `comment-${workIndex + 1}-${commentIndex + 1}`,
+        workId: `work-${workIndex + 1}`,
+        text: `第 ${commentIndex + 1} 条评论:${'装了很多工具但不知道先用哪个,希望有明确步骤。'.repeat(8)}`,
+      })),
+    }));
+    const benchmarkEvidence = Array.from({ length: 20 }, (_, index) => ({
+      id: `benchmark-${index + 1}`,
+      platform: 'douyin' as const,
+      kind: 'competitor_post' as const,
+      bucket: 'patterns' as const,
+      title: `对标爆款 ${index + 1}`,
+      rawText: '强判断开头与步骤拆解'.repeat(30),
+      summary: '强判断开头与步骤拆解'.repeat(20),
+      tags: ['强判断', '步骤拆解'],
+      relatedTopicIds: [],
+      relatedScriptIds: [],
+      evidenceRefs: [],
+      selectedForGeneration: true,
+      confidence: 'high' as const,
+      createdAt: now(),
+      updatedAt: now(),
+    }));
+    const painInsights = Array.from({ length: 20 }, (_, index) => ({
+      ...painInsightFixture(),
+      id: `pain-${index + 1}`,
+      title: `选择困难 ${index + 1}`,
+      summary: '用户收藏了很多工具,但不知道开工时先用哪个。'.repeat(12),
+    }));
+
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot,
+      activePositioningVersion: null,
+      benchmarkEvidence,
+      painInsights,
+    });
+    const selectedText = [
+      context.profileSummary,
+      context.accountIntentSummary,
+      context.activePositioningSummary,
+      ...context.topWorks,
+      ...context.commentSignals,
+      ...context.benchmarkSignals,
+      ...context.painSignals,
+    ].join('\n');
+
+    expect(context.rawTextBudget).toBe(selectedText.length);
+    expect(context.rawTextBudget).toBeLessThanOrEqual(6000);
+    expect(context.topWorks.length).toBeGreaterThan(0);
+    expect(context.commentSignals.length).toBeGreaterThan(0);
+    expect(context.benchmarkSignals.length).toBeGreaterThan(0);
+    expect(context.painSignals.length).toBeGreaterThan(0);
+    expect(context.evidenceRefs.every((item) => selectedText.includes(`[${item.id}]`))).toBe(true);
+  });
+
+  it('keeps the account strategy prompt based on work reports instead of full transcripts', () => {
+    const snapshot = snapshotFixture();
+    snapshot.works = Array.from({ length: 10 }, (_, workIndex) => ({
+      ...snapshot.works[0],
+      id: `work-${workIndex + 1}`,
+      awemeId: `aweme-${workIndex + 1}`,
+      title: `高互动作品 ${workIndex + 1}`,
+      transcript: {
+        ...snapshot.works[0].transcript!,
+        text: `第 ${workIndex + 1} 条视频文字稿:${'先判断任务,再选择工具,并说明安装顺序、常见误区和排查路径。'.repeat(120)}`,
+      },
+      interactionScore: 100000 - workIndex,
+      comments: Array.from({ length: 20 }, (__, commentIndex) => ({
+        ...snapshot.works[0].comments[0],
+        id: `comment-${workIndex + 1}-${commentIndex + 1}`,
+        workId: `work-${workIndex + 1}`,
+        text: `第 ${commentIndex + 1} 条评论:装了很多工具但不知道先用哪个。`,
+      })),
+    }));
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot,
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+      completedWorkReports: [workStrategyReportFixture()],
+    });
+
+    const prompt = service.buildStrategyPrompt(context);
+
+    expect(prompt.length).toBeLessThan(20000);
+    expect(prompt).toContain('"workReports"');
+    expect(prompt).toContain('work_report_work-1');
+    expect(prompt).toContain('高收藏和高分享说明用户需要操作顺序');
+    expect(prompt).not.toContain('"workEvidenceBundles"');
+    expect(prompt).not.toContain('"transcriptText"');
+    expect(prompt).not.toContain('第 1 条视频文字稿:');
+  });
+
+  it('strategy prompt forbids using comments or bio as final direction titles', () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const prompt = service.buildStrategyPrompt(context);
+
+    expect(prompt).toContain('不能把评论原文、账号简介、作品标题直接当成方向或选题');
+    expect(prompt).toContain('"directions"');
+    expect(prompt).toContain('"diagnosisSummary"');
+  });
+
+  it('passes user calibration and publish retrospectives into the strategy prompt', () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+      calibrationRecords: [{
+        id: 'calibration-1',
+        accountId: 'account-1',
+        reportId: 'report-1',
+        source: 'manual_calibration',
+        accuracy: 'partial',
+        targetAudienceNotes: '传统行业老板',
+        positioningNotes: '强调业务增长,不要只讲工具清单',
+        personaNotes: '增长顾问',
+        forbiddenTopics: ['纯工具合集'],
+        forbiddenExpressions: ['保姆级教程'],
+        confirmedDirectionIds: [],
+        rejectedDirectionIds: [],
+        rejectedEvidenceIds: ['work-1'],
+        operatorExperienceNotes: '方法论内容收藏更好。',
+        createdAt: now(),
+        updatedAt: now(),
+      }],
+      publishRetrospectives: [{
+        id: 'review-1',
+        accountId: 'account-1',
+        publishPackageId: 'publish-1',
+        outcome: 'below_expectation',
+        sourceMode: 'llm',
+        expectedGoal: ['验证增长方向'],
+        actualSignals: ['播放 100', '评论 0'],
+        diagnosis: '纯工具清单转粉弱。',
+        nextAction: 'adjust',
+        insight: {
+          sourceMode: 'llm',
+          confidence: 'medium',
+          conclusion: 'published result should change next round',
+          causeSignals: [{
+            key: 'hook_strength',
+            label: 'weak hook',
+            score: 30,
+            judgment: 'generic opening did not convert',
+            evidence: ['retention dropped early'],
+          }],
+          directionAdjustments: [{
+            directionTitle: 'trust direction',
+            action: 'decrease',
+            reason: 'trust content needs more proof before scale',
+          }],
+          nextTopicSignals: [{
+            title: 'answer the first setup question',
+            source: 'answer_comment',
+            targetAudience: 'new users',
+            userProblem: 'do not know the first step',
+            reason: 'comments ask for it',
+          }],
+          scriptConstraints: [{
+            type: 'hook',
+            instruction: 'open with a concrete user scene',
+            avoid: 'avoid repeating generic tool list',
+          }],
+          positioningImpact: 'audience needs practical setup help',
+          nextRoundBrief: 'next round should answer setup blockers',
+        },
+        createdAt: now(),
+        updatedAt: now(),
+      }],
+    });
+
+    const prompt = service.buildStrategyPrompt(context);
+
+    expect(context.forbiddenTopics).toEqual(['纯工具合集']);
+    expect(context.rejectedEvidenceIds).toEqual(['work-1']);
+    expect(prompt).toContain('用户校准');
+    expect(prompt).toContain('发布复盘');
+    expect(prompt).toContain('appliedCalibrationSummary');
+    expect(prompt).toContain('evidenceGaps');
+    expect(context.retrospectiveSummary.join('\n')).toContain('next round should answer setup blockers');
+    expect(context.retrospectiveSummary.join('\n')).toContain('avoid repeating generic tool list');
+  });
+
+  it('rejects copied source text as direction titles', () => {
+    const copiedTitle = '装了很多,收藏了很多,但真正开工的时候还是不知道该先用哪个。';
+    const raw = JSON.stringify({
+      diagnosisSummary: diagnosisSummaryFixture(),
+      positioningProposal: positioningProposalJson(),
+      evidenceRefs: [{
+        id: 'ev_1',
+        sourceType: 'owned_comment',
+        sourceId: 'c1',
+        label: '评论痛点',
+        quote: '装了很多,收藏了很多,但真正开工的时候还是不知道该先用哪个。',
+        reason: '选择困难',
+      }],
+      directions: [
+        { ...(directionJson('growth', '新手认知纠偏') as any), title: copiedTitle },
+        directionJson('trust', '工具实测与完整教程'),
+        directionJson('interaction', '实操答疑与问题复盘'),
+      ],
+      taskSuggestions: [],
+    });
+
+    expect(() => service.parseStrategyResponse(raw)).toThrowError(/方向标题疑似复制原始证据/);
+  });
+
+  it('keeps a partial LLM response instead of rejecting the whole result for short topic lists', () => {
+    const payload = strategyJsonFixture() as any;
+    payload.directions[0].topics = payload.directions[0].topics.slice(0, 2);
+
+    const parsed = service.parseStrategyResponse(JSON.stringify(payload));
+
+    expect(parsed.directions[0].topics).toHaveLength(2);
+    expect(parsed.workReports).toHaveLength(1);
+  });
+
+  it('rejects a copied transcript or oversized text as the follow reason', () => {
+    const payload = strategyJsonFixture() as any;
+    const copiedText = '装了很多,收藏了很多,但真正开工的时候还是不知道该先用哪个。';
+    payload.evidenceRefs[0].quote = copiedText;
+    payload.diagnosisSummary.strongestFollowReason = copiedText;
+
+    expect(() => service.parseStrategyResponse(JSON.stringify(payload))).toThrowError(/用户关注理由疑似复制原始证据/);
+
+    payload.diagnosisSummary.strongestFollowReason = '这是一段没有归纳的冗长文字稿'.repeat(20);
+    expect(() => service.parseStrategyResponse(JSON.stringify(payload))).toThrowError(/用户关注理由过长/);
+  });
+
+  it('maps parsed strategy into positioning proposal, directions, topics and report', () => {
+    const parsed = service.parseStrategyResponse(JSON.stringify(strategyJsonFixture()));
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+      calibrationRecords: [{
+        id: 'calibration-1',
+        accountId: 'account-1',
+        source: 'manual_calibration',
+        accuracy: 'partial',
+        targetAudienceNotes: '传统行业老板',
+        positioningNotes: '强调业务增长',
+        personaNotes: '',
+        forbiddenTopics: [],
+        forbiddenExpressions: [],
+        confirmedDirectionIds: [],
+        rejectedDirectionIds: [],
+        rejectedEvidenceIds: [],
+        operatorExperienceNotes: '',
+        createdAt: now(),
+        updatedAt: now(),
+      }],
+      publishRetrospectives: [],
+    });
+    const result = service.buildDiagnosisResultFromStrategy({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      parsed,
+      localResult: localResultFixture(),
+      context,
+      sourceMode: 'llm',
+      sourceLabel: 'LLM 证据诊断',
+    });
+
+    expect(result.strategyReport.sourceMode).toBe('llm');
+    expect(result.proposal.proposedVersion.persona).toBe('Vibe Coding 实操陪跑教练');
+    expect(result.directions.map((item) => item.role)).toEqual(['growth', 'trust', 'interaction']);
+    expect(result.topics.length).toBe(9);
+    expect(result.topics[0].targetAudience).toContain('新手');
+    expect(result.strategyReport.confidenceLevel).toBeDefined();
+    expect(result.strategyReport.evidenceGaps?.length).toBeGreaterThan(0);
+    expect(result.strategyReport.appliedCalibrationSummary?.join('')).toContain('传统行业老板');
+    expect(result.reportMarkdown).toContain('LLM 证据诊断');
+  });
+
+  it('fills missing topics per direction when the LLM returns an incomplete direction topic list', () => {
+    const payload = strategyJsonFixture() as any;
+    payload.directions[2].topics = [];
+    const parsed = service.parseStrategyResponse(JSON.stringify(payload));
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = service.buildDiagnosisResultFromStrategy({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      parsed,
+      localResult: localResultFixture(),
+      context,
+      sourceMode: 'llm',
+      sourceLabel: 'LLM 证据诊断',
+    });
+
+    expect(result.directions.every((direction) =>
+      result.topics.some((topic) => topic.directionId === direction.id),
+    )).toBe(true);
+    expect(result.topics.filter((topic) => topic.expectedRole === 'interaction')).toHaveLength(1);
+    expect(result.topics.find((topic) => topic.expectedRole === 'interaction')?.title).toContain('评论问题');
+  });
+
+  it('calls the LLM inside the orchestrator and returns an evidence diagnosis', async () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+      completedWorkReports: [workStrategyReportFixture()],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult: localResultFixture(),
+    });
+
+    expect(llm.askWithSystem).toHaveBeenCalled();
+    expect(result.strategyReport.sourceMode).toBe('llm');
+    expect(result.strategyReport.sourceLabel).toBe('LLM 证据诊断');
+    expect(result.strategyReport.workReports?.[0].id).toBe('work_report_work-1');
+    expect(result.proposal.evidenceItemIds.length).toBeGreaterThan(0);
+    expect(result.strategyReport.evidenceRefs[0].id).toBe('work_report_work-1');
+    expect(result.strategyReport.evidenceRefs[0].sourceId).toBe('work-1');
+    expect(result.strategyReport.evidenceRefs[0].quote).toBe('新手需要先判断任务再选择工具');
+    expect(result.strategyReport.evidenceRefs[0].reason).toBe('高收藏和高分享说明用户需要操作顺序。');
+    expect(result.strategyReport.evidenceRefs[0].stage).toBe('work_report');
+    expect(result.strategyReport.evidenceRefs[0].analyzed).toBe(true);
+    expect(result.directions.every((item) => item.sourceEvidenceIds.length > 0)).toBe(true);
+    expect(result.directions.every((item) => item.sourceEvidenceIds.includes('work_report_work-1'))).toBe(true);
+    expect(result.topics.every((item) => (item.evidenceRefs || []).length > 0)).toBe(true);
+    expect(result.topics.every((item) => (item.evidenceRefs || []).includes('work_report_work-1'))).toBe(true);
+    expect(result.tasks.every((item) => item.relatedEvidenceIds.length > 0)).toBe(true);
+  });
+
+  it('uses completed work reports even when account-level LLM does not return workReports', async () => {
+    const response = strategyJsonFixture() as any;
+    response.workReports = [];
+    llm.askWithSystem.mockReturnValue(of(JSON.stringify(response)));
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+      completedWorkReports: [workStrategyReportFixture()],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult: localResultFixture(),
+    });
+
+    expect(result.strategyReport.workReports?.[0]).toMatchObject({
+      id: 'work_report_work-1',
+      sourceMode: 'llm',
+    });
+    expect(result.strategyReport.evidenceRefs.some((item) => item.id === 'work_report_work-1')).toBe(true);
+    expect(result.strategyReport.evidenceGaps?.join('')).not.toContain('缺少基于视频文字稿的作品级报告');
+  });
+
+  it('maps raw comment evidence ids from the LLM response back to the parent work report', async () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+    const commentRefId = context.evidenceRefs.find((item) => item.sourceType === 'owned_comment')!.id;
+    const response = strategyJsonFixture() as any;
+    response.workReports = [workReportJson(commentRefId)];
+    response.directions = [
+      directionJson('growth', 'Vibe Coding 新手入门与认知纠偏', commentRefId),
+      directionJson('trust', 'Vibe Coding 工具实测与完整教程', commentRefId),
+      directionJson('interaction', 'Vibe Coding 实操答疑与问题复盘', commentRefId),
+    ];
+    response.taskSuggestions = [{
+      title: '确认账号定位提案',
+      reason: '定位会约束后续方向、选题和脚本。',
+      type: 'positioning',
+      column: 'today',
+      growthImpactScore: 94,
+      relatedEvidenceIds: [commentRefId],
+    }];
+    llm.askWithSystem.mockReturnValue(of(JSON.stringify(response)));
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult: localResultFixture(),
+    });
+
+    expect(result.directions.flatMap((item) => item.sourceEvidenceIds)).not.toContain(commentRefId);
+    expect(result.directions.every((item) => item.sourceEvidenceIds.includes('work_report_work-1'))).toBe(true);
+    expect(result.topics.every((item) => item.evidenceRefs?.includes('work_report_work-1'))).toBe(true);
+    expect(result.tasks.every((item) => item.relatedEvidenceIds.includes('work_report_work-1'))).toBe(true);
+  });
+
+  it('keeps unused raw comment refs as raw context instead of analyzed judgments', async () => {
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult: localResultFixture(),
+    });
+
+    const rawCommentRefs = result.strategyReport.evidenceRefs.filter((item) => item.sourceType === 'owned_comment');
+    expect(rawCommentRefs.length).toBeGreaterThan(0);
+    expect(rawCommentRefs.every((item) => item.stage === 'raw_context')).toBe(true);
+    expect(rawCommentRefs.every((item) => item.analyzed === false)).toBe(true);
+    expect(rawCommentRefs.every((item) => item.reason.includes('原始评论样本'))).toBe(true);
+  });
+
+  it('records the parse failure stage when the LLM returns unusable JSON', async () => {
+    llm.askWithSystem.mockReturnValue(of('不是 JSON'));
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult: localResultFixture(),
+    });
+
+    expect(result.strategyReport.sourceMode).toBe('local_fallback');
+    expect(result.strategyReport.failureReason).toContain('策略响应中没有可解析 JSON');
+    expect(result.strategyReport.diagnostics?.some((item) =>
+      item.stage === 'llm_response' && item.status === 'success',
+    )).toBe(true);
+    expect(result.strategyReport.diagnostics?.some((item) =>
+      item.stage === 'llm_parse' && item.status === 'failed' && item.detail?.includes('策略响应中没有可解析 JSON'),
+    )).toBe(true);
+  });
+
+  it('falls back to the local diagnosis when the LLM request fails', async () => {
+    llm.askWithSystem.mockReturnValue(throwError(() => new Error('upstream unavailable')));
+    const localResult = localResultFixture();
+    localResult.topics = [{
+      id: 'local-topic-1',
+      title: '本地兜底选题',
+      directionId: 'direction-growth',
+      column: 'growth 方向',
+      trafficLayer: 'broad',
+      contentGoal: '保留真实证据继续可追溯',
+      targetAudience: '新手',
+      userProblem: '不知道从哪一步开始',
+      evidenceRefs: ['e1'],
+      viralPatternRefs: ['问题-步骤结构'],
+      expectedRole: 'growth',
+      priority: 'high',
+      fitReason: '来自本地诊断',
+      source: '本地规则诊断',
+      requiredMaterials: [],
+      scriptStatus: 'outline',
+      riskNote: '需要人工复核',
+    }];
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      context,
+      localResult,
+    });
+
+    expect(result.strategyReport.sourceMode).toBe('local_fallback');
+    expect(result.strategyReport.sourceLabel).toBe('本地规则诊断(LLM失败)');
+    expect(result.strategyReport.failureReason).toContain('upstream unavailable');
+    expect(result.strategyReport.evidenceGaps?.join('')).toContain('upstream unavailable');
+    expect(result.strategyReport.diagnostics?.some((item) =>
+      item.stage === 'context_built' && item.detail?.includes('可补作品报告的文字稿 1 条'),
+    )).toBe(true);
+    expect(result.strategyReport.diagnostics?.some((item) =>
+      item.stage === 'llm_fallback' && item.detail?.includes('upstream unavailable'),
+    )).toBe(true);
+    expect(result.strategyReport.diagnostics?.some((item) =>
+      item.stage === 'result_built' && item.status === 'fallback',
+    )).toBe(true);
+    expect(result.proposal.reason).toContain('本地规则诊断兜底');
+    expect(result.strategyReport.evidenceRefs.length).toBeGreaterThan(0);
+    expect(result.strategyReport.evidenceRefs[0].sourceId).toBe('work-1');
+    expect(result.directions.every((item) => item.sourceEvidenceIds.length > 0)).toBe(true);
+    expect(result.topics[0].evidenceRefs?.length).toBeGreaterThan(0);
+  });
+
+  it('uses real snapshot evidence refs in local fallback when legacy local diagnosis has no evidence ids', async () => {
+    llm.askWithSystem.mockReturnValue(throwError(() => new Error('upstream unavailable')));
+    const localResult = localResultFixture();
+    localResult.proposal.evidenceItemIds = [];
+    for (const direction of localResult.directions) {
+      direction.sourceEvidenceIds = [];
+      direction.commentPainRefs = [];
+    }
+    for (const topic of localResult.topics) {
+      topic.evidenceRefs = [];
+      topic.viralPatternRefs = [];
+    }
+    const snapshot = {
+      ...snapshotFixture(),
+      evidenceItemIds: [],
+    };
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot,
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot,
+      context,
+      localResult,
+    });
+
+    expect(result.strategyReport.sourceMode).toBe('local_fallback');
+    expect(result.strategyReport.evidenceRefs.some((item) => item.sourceId === 'work-1')).toBe(true);
+    expect(result.directions.every((item) => item.sourceEvidenceIds.length > 0)).toBe(true);
+    expect(result.topics.every((item) => (item.evidenceRefs || []).length > 0)).toBe(true);
+    expect(result.strategyReport.directions.every((direction) => direction.topics.every((topic) => topic.evidenceRefs.length > 0))).toBe(true);
+  });
+
+  it('does not generate work reports when every work is missing transcript text', async () => {
+    const snapshot = snapshotFixture();
+    delete snapshot.works[0].transcript;
+    const context = service.buildStrategyContext({
+      account: accountFixture(),
+      snapshot,
+      activePositioningVersion: null,
+      benchmarkEvidence: [],
+      painInsights: [],
+    });
+
+    const result = await service.generateDiagnosis({
+      account: accountFixture(),
+      snapshot,
+      context,
+      localResult: localResultFixture(),
+    });
+
+    expect(context.workEvidenceBundles).toEqual([]);
+    expect(result.strategyReport.workReports).toEqual([]);
+    expect(result.strategyReport.confidenceLevel).toBe('low');
+    expect(result.strategyReport.evidenceGaps?.join('')).toContain('缺少视频文字稿');
+    expect(result.reportMarkdown).toContain('缺少视频文字稿');
+  });
+});
+
+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: ['e1'],
+    profile: {
+      nickname: 'FredTalk',
+      signature: '关注小白如何从 0 到 1 学习 AI 实操能力',
+      followerCount: 0,
+      totalFavorited: 175144,
+      followingCount: 300,
+      awemeCount: 53,
+    },
+    works: [{
+      id: 'work-1',
+      accountId: 'account-1',
+      awemeId: 'a1',
+      title: '新手小白安装这个 Skills 就行够了',
+      desc: '很多人开始给 Codex 装 Skills 后,不知道该先用哪个。',
+      transcript: {
+        text: '新手先判断任务,再选择对应工具。视频逐步说明安装顺序、常见误区和遇到问题时如何排查。',
+        source: 'asr',
+        confidence: 'high',
+        capturedAt: now(),
+      },
+      metrics: { likeCount: 58850, commentCount: 1036, collectCount: 61773, shareCount: 9581 },
+      interactionScore: 113902,
+      isDeepSampled: true,
+      comments: [{
+        id: 'comment-1',
+        workId: 'work-1',
+        text: '装了很多,收藏了很多,但真正开工的时候还是不知道该先用哪个。',
+        likeCount: 20,
+        capturedAt: now(),
+      }],
+      structure: {
+        hook: '先否定盲目安装',
+        topic: '工具选择顺序',
+        style: '口语化实操',
+        cta: '评论区留言',
+      },
+      capturedAt: now(),
+    }],
+  };
+}
+
+function painInsightFixture(): IpCommentPainInsight {
+  return {
+    id: 'pain-1',
+    title: '选择困难',
+    category: 'how_to',
+    severity: 'high',
+    summary: '用户收藏了很多工具,但不知道开工时先用哪个。',
+    commentCount: 16,
+    keywordFrequency: [{ keyword: '先用哪个', count: 6 }],
+    representativeComments: ['不知道该先用哪个', '有没有顺序清单'],
+    evidenceItemIds: ['e1'],
+    suggestedTopics: ['新手先用哪个工具'],
+    scriptAngles: ['先判断任务,再选工具'],
+    riskBoundaries: ['不做工具堆砌'],
+    createdAt: now(),
+    updatedAt: now(),
+  };
+}
+
+function strategyJsonFixture(): object {
+  return {
+    diagnosisSummary: diagnosisSummaryFixture(),
+    positioningProposal: positioningProposalJson(),
+    evidenceRefs: [{
+      id: 'ev_1',
+      sourceType: 'owned_work',
+      sourceId: 'work-1',
+      label: '高互动作品',
+      quote: '新手需要先判断任务再选择工具',
+      reason: '高收藏和高分享说明用户需要操作顺序。',
+    }],
+    workReports: [workReportJson('ip_evidence_account-1_comment_comment-1')],
+    directions: [
+      directionJson('growth', 'Vibe Coding 新手入门与认知纠偏'),
+      directionJson('trust', 'Vibe Coding 工具实测与完整教程'),
+      directionJson('interaction', 'Vibe Coding 实操答疑与问题复盘'),
+    ],
+    taskSuggestions: [{
+      title: '确认账号定位提案',
+      reason: '定位会约束后续方向、选题和脚本。',
+      type: 'positioning',
+      column: 'today',
+      growthImpactScore: 94,
+      relatedEvidenceIds: ['ev_1'],
+    }],
+  };
+}
+
+function workReportJson(commentEvidenceId = 'ip_evidence_account-1_comment_comment-1'): object {
+  return {
+    id: 'work_report_work-1',
+    workId: 'work-1',
+    reports: [{
+      kind: 'operation',
+      headline: '新手工具选择顺序卡点',
+      summary: '新手需要先判断任务再选择工具',
+      evidenceSignals: ['高收藏', '高分享', '评论集中问先用哪个'],
+      strategyJudgment: '高收藏和高分享说明用户需要操作顺序。',
+      supportingEvidenceIds: [commentEvidenceId],
+      confidence: 'high',
+      gaps: [],
+    }],
+  };
+}
+
+function workStrategyReportFixture(): IpAccountWorkStrategyReport {
+  return {
+    id: 'work_report_work-1',
+    workId: 'work-1',
+    sourceEvidenceId: 'ip_evidence_account-1_work_work-1',
+    title: '新手小白安装这六个 Skills 就行够了',
+    sourceMode: 'llm',
+    reports: [{
+      id: 'work_report_work-1_operation_1',
+      kind: 'operation',
+      headline: '新手工具选择顺序卡点',
+      summary: '新手需要先判断任务再选择工具',
+      evidenceSignals: ['高收藏', '高分享', '评论集中问先用哪个'],
+      strategyJudgment: '高收藏和高分享说明用户需要操作顺序。',
+      supportingEvidenceIds: ['ip_evidence_account-1_comment_comment-1'],
+      confidence: 'high',
+      gaps: [],
+    }],
+    supportingCommentEvidenceIds: ['ip_evidence_account-1_comment_comment-1'],
+    createdAt: now(),
+  };
+}
+
+function directionJson(role: 'growth' | 'trust' | 'interaction', title: string, evidenceId = 'work_report_work-1'): object {
+  return {
+    role,
+    title,
+    conclusion: '这是经过账号内容和评论问题归纳后的系列方向。',
+    targetAudience: role === 'growth' ? '希望从 0 到 1 上手的新手' : '正在比较工具和方法的用户',
+    purpose: '把高互动问题变成连续栏目。',
+    sourceEvidenceIds: [evidenceId],
+    viralPatternRefs: ['强判断开头', '步骤拆解'],
+    commentPainRefs: [evidenceId],
+    cadenceSuggestion: '每周 1-2 条',
+    observeMetrics: ['播放量', '评论数', '新粉'],
+    fitReason: '符合账号当前内容优势。',
+    risks: ['不复制原视频标题和口播。'],
+    topics: Array.from({ length: 3 }, (_, index) => ({
+      title: `${title}:第 ${index + 1} 个可拍切口`,
+      targetAudience: '新手用户',
+      userProblem: index === 0 ? '不知道开工时先做什么。' : index === 1 ? '工具很多但缺少选择标准。' : '照着教程操作后仍无法复现结果。',
+      expectedImpact: role === 'growth' ? '涨粉' : role === 'trust' ? '信任' : '互动',
+      evidenceRefs: [evidenceId],
+      viralPatternRefs: ['强判断开头'],
+      avoidCopyingRisk: '只借结构,不复制案例。',
+      fitReason: '能承接当前账号的工具实操定位。',
+    })),
+  };
+}
+
+function diagnosisSummaryFixture(): any {
+  return {
+    accountSnapshot: '账号内容集中在 AI 工具实操入门。',
+    currentIpGuess: 'Vibe Coding 实操陪跑教练',
+    actualAudience: '希望快速上手 AI 工具的新手',
+    positioningMismatch: '需要减少工具堆砌,强化操作顺序。',
+    strongestFollowReason: '帮新手把工具真正用起来。',
+    biggestGrowthBlocker: '系列化不足。',
+    highInteractionPattern: '先指出误区,再给操作路径。',
+    commentPainSummary: '不知道先用哪个工具。',
+    sevenDayPriority: '围绕三类方向各发一条测试。',
+  };
+}
+
+function positioningProposalJson(): any {
+  return {
+    targetAudience: '希望从 0 到 1 上手 Vibe Coding 的新手',
+    persona: 'Vibe Coding 实操陪跑教练',
+    followReason: '用真实流程帮新手少走工具选择弯路。',
+    contentPillars: ['入门认知纠偏', '工具实测教程', '实操答疑复盘'],
+    expressionStyle: ['口语化', '具体案例优先'],
+    boundaries: ['不堆工具清单', '不夸大效果'],
+    suitableViralPatterns: ['强判断开头', '问题-步骤结构'],
+    assumptionsToValidate: ['新手是否更需要顺序而不是工具合集'],
+    observeMetrics: ['播放量', '评论数', '收藏数', '新粉'],
+  };
+}
+
+function localResultFixture(): LocalAccountDiagnosisResult {
+  const proposal: IpPositioningProposal = {
+    id: 'proposal-local',
+    accountId: 'account-1',
+    snapshotId: 'snapshot-1',
+    status: 'proposed',
+    reason: '本地规则兜底',
+    proposedVersion: {
+      accountId: 'account-1',
+      targetAudience: '新手',
+      persona: 'AI 工具实操账号',
+      followReason: '帮新手上手工具',
+      contentPillars: ['入门', '教程', '答疑'],
+      expressionStyle: ['口语化'],
+      boundaries: ['不夸大'],
+      suitableViralPatterns: ['强判断'],
+      evidenceItemIds: ['e1'],
+      assumptionsToValidate: ['是否需要工具顺序'],
+      observeMetrics: ['播放量'],
+    },
+    evidenceItemIds: ['e1'],
+    createdAt: now(),
+    updatedAt: now(),
+  };
+  const directions: IpContentDirection[] = [
+    localDirection('growth'),
+    localDirection('trust'),
+    localDirection('interaction'),
+  ];
+  const topics: IpTopic[] = [];
+  return {
+    mode: 'data_diagnosis',
+    reportMarkdown: '# 本地诊断',
+    scores: {
+      positioningClarity: 70,
+      contentStructure: 70,
+      interactionConversion: 70,
+      recognizability: 70,
+      sustainability: 70,
+    },
+    proposal,
+    directions,
+    topics,
+    tasks: [],
+  };
+}
+
+function localDirection(role: IpContentDirection['role']): IpContentDirection {
+  return {
+    id: `direction-${role}`,
+    accountId: 'account-1',
+    positioningVersionId: 'pending',
+    role,
+    title: `${role} 方向`,
+    targetAudience: '新手',
+    purpose: '本地兜底方向',
+    sourceEvidenceIds: ['e1'],
+    viralPatternRefs: [],
+    commentPainRefs: [],
+    topicIds: [],
+    cadenceSuggestion: '每周 1 条',
+    observeMetrics: ['播放量'],
+    createdAt: now(),
+    updatedAt: now(),
+  };
+}
+
+function now(): string {
+  return '2026-06-15T00:00:00.000Z';
+}

+ 1364 - 0
src/app/services/ip-account-strategy-orchestrator.service.ts

@@ -0,0 +1,1364 @@
+import { Injectable } from '@angular/core';
+import { firstValueFrom, timeout } from 'rxjs';
+import {
+  IpAccountSnapshot,
+  IpAccountCalibrationRecord,
+  IpAccountStrategyDirectionResult,
+  IpAccountStrategyDiagnosticEvent,
+  IpAccountStrategyEvidenceRef,
+  IpAccountStrategyImpact,
+  IpAccountStrategyReport,
+  IpAccountStrategySourceType,
+  IpAccountStrategyTopicCandidate,
+  IpAccountWorkEvidenceBundle,
+  IpAccountWorkStrategyReport,
+  IpBenchmarkEvidence,
+  IpCommentPainInsight,
+  IpContentDirection,
+  IpEvidenceItem,
+  IpMonitoredAccount,
+  IpOperationTask,
+  IpOperationTaskColumn,
+  IpOperationTaskType,
+  IpPositioningProposal,
+  IpPositioningVersion,
+  IpPublishRetrospectiveRecord,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LocalAccountDiagnosisResult } from './ip-account-diagnosis.service';
+import { buildAccountStrategyPrompt } from './ip-operator-prompts';
+import { LlmService } from './llm.service';
+import { accountSourceEvidenceId } from './ip-operator-evidence-adapter';
+import { IpAccountWorkReportService, ParsedIpAccountWorkReportInput } from './ip-account-work-report.service';
+export { IP_ACCOUNT_STRATEGY_SYSTEM_PROMPT } from './ip-operator-prompts';
+import { IP_ACCOUNT_STRATEGY_SYSTEM_PROMPT } from './ip-operator-prompts';
+
+const IP_ACCOUNT_STRATEGY_LLM_TIMEOUT_MS = 120_000;
+const IP_ACCOUNT_STRATEGY_LLM_MAX_TOKENS = 6144;
+
+export interface IpAccountStrategyContext {
+  accountId: string;
+  accountName: string;
+  profileSummary: string;
+  accountIntentSummary: string;
+  activePositioningSummary: string;
+  topWorks: string[];
+  commentSignals: string[];
+  benchmarkSignals: string[];
+  painSignals: string[];
+  evidenceRefs: IpAccountStrategyEvidenceRef[];
+  workReports: IpAccountWorkStrategyReport[];
+  workEvidenceBundles: IpAccountWorkEvidenceBundle[];
+  calibrationSummary: string[];
+  rejectedEvidenceIds: string[];
+  forbiddenTopics: string[];
+  forbiddenExpressions: string[];
+  retrospectiveSummary: string[];
+  rawTextBudget: number;
+}
+
+export interface BuildStrategyContextInput {
+  account: IpMonitoredAccount;
+  snapshot: IpAccountSnapshot;
+  activePositioningVersion: IpPositioningVersion | null;
+  benchmarkEvidence: Array<IpBenchmarkEvidence | IpEvidenceItem>;
+  painInsights: IpCommentPainInsight[];
+  completedWorkReports?: IpAccountWorkStrategyReport[];
+  calibrationRecords?: IpAccountCalibrationRecord[];
+  publishRetrospectives?: IpPublishRetrospectiveRecord[];
+}
+
+export interface ParsedIpAccountStrategyResponse {
+  diagnosisSummary: IpAccountStrategyReport['diagnosisSummary'];
+  positioningProposal: {
+    targetAudience: string;
+    persona: string;
+    followReason: string;
+    contentPillars: string[];
+    expressionStyle: string[];
+    boundaries: string[];
+    suitableViralPatterns: string[];
+    assumptionsToValidate: string[];
+    observeMetrics: string[];
+  };
+  evidenceRefs: IpAccountStrategyEvidenceRef[];
+  workReports: ParsedIpAccountWorkReportInput[];
+  appliedCalibrationIds?: string[];
+  appliedCalibrationSummary?: string[];
+  confidenceLevel?: IpAccountStrategyReport['confidenceLevel'];
+  evidenceGaps?: string[];
+  directions: Array<Omit<IpAccountStrategyDirectionResult, 'id' | 'topics'> & {
+    topics: Array<Omit<IpAccountStrategyTopicCandidate, 'id' | 'directionId' | 'expectedRole'> & {
+      expectedImpact?: IpAccountStrategyImpact;
+    }>;
+  }>;
+  taskSuggestions: Array<{
+    title: string;
+    reason: string;
+    type: IpOperationTaskType;
+    column: IpOperationTaskColumn;
+    growthImpactScore: number;
+    relatedEvidenceIds: string[];
+  }>;
+}
+
+export interface StrategyDiagnosisResult extends LocalAccountDiagnosisResult {
+  strategyReport: IpAccountStrategyReport;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountStrategyOrchestratorService {
+  constructor(
+    private readonly llm: LlmService,
+    private readonly workReportService: IpAccountWorkReportService,
+  ) {}
+
+  buildStrategyContext(input: BuildStrategyContextInput): IpAccountStrategyContext {
+    const { account, snapshot, activePositioningVersion, benchmarkEvidence, painInsights } = input;
+    const calibrationRecords = (input.calibrationRecords || []).filter((item) => item.accountId === account.id).slice(0, 8);
+    const publishRetrospectives = (input.publishRetrospectives || []).filter((item) => item.accountId === account.id).slice(0, 8);
+    const evidenceRefs: IpAccountStrategyEvidenceRef[] = [];
+    const profile = snapshot.profile;
+    const rankedWorks = [...snapshot.works].sort((left, right) => right.interactionScore - left.interactionScore);
+    const profileRefId = accountSourceEvidenceId(snapshot.accountId, 'profile', `${snapshot.accountId}:profile`);
+    evidenceRefs.push({
+      id: profileRefId,
+      sourceType: 'positioning_version',
+      sourceId: `${snapshot.accountId}:profile`,
+      label: `账号主页:${profile.nickname || account.displayName}`,
+      quote: this.compact(profile.signature || '无简介', 96),
+      reason: '账号主页信息用于识别当前公开定位,但不能单独作为最终定位结论。',
+    });
+
+    const candidateTopWorks = rankedWorks.slice(0, 8).map((work, index) => {
+      const refId = accountSourceEvidenceId(snapshot.accountId, 'work', work.id);
+      const transcriptText = this.compact(work.transcript?.text || '', 900);
+      const hasTranscript = transcriptText.length >= 20;
+      evidenceRefs.push({
+        id: refId,
+        sourceType: 'owned_work',
+        sourceId: work.id,
+        label: `高互动作品 ${index + 1}`,
+        quote: this.compact(work.title || work.desc, 96),
+        reason: hasTranscript
+          ? `已取得视频文字稿,互动分 ${work.interactionScore},赞 ${work.metrics.likeCount || 0},评 ${work.metrics.commentCount || 0},藏 ${work.metrics.collectCount || 0},转 ${work.metrics.shareCount || 0}`
+          : `缺少视频文字稿,不能生成完整作品分析;互动分 ${work.interactionScore},赞 ${work.metrics.likeCount || 0},评 ${work.metrics.commentCount || 0},藏 ${work.metrics.collectCount || 0},转 ${work.metrics.shareCount || 0}`,
+      });
+      return [
+        `[${refId}] ${this.compact(work.title || work.desc, 90)}`,
+        `数据:播放 ${work.metrics.playCount || 0} / 赞 ${work.metrics.likeCount || 0} / 评 ${work.metrics.commentCount || 0} / 藏 ${work.metrics.collectCount || 0} / 转 ${work.metrics.shareCount || 0}`,
+        hasTranscript
+          ? `视频文字稿:已取得(${work.transcript?.source || 'unknown'}),账号级诊断只消费已完成的作品报告摘要。`
+          : '视频文字稿:缺失。禁止把标题、描述、互动数据或评论样本当成完整视频内容分析。',
+        hasTranscript
+          ? `结构初判:${work.structure?.hook || '未识别开头'} / ${work.structure?.topic || '未识别主题'} / ${work.structure?.style || '未识别风格'} / ${work.structure?.cta || '未识别转化'}`
+          : '',
+      ].join(';');
+    });
+
+    const candidateCommentSignals = rankedWorks
+      .filter((work) => work.isDeepSampled)
+      .flatMap((work) => work.comments.map((comment, index) => {
+        const refId = accountSourceEvidenceId(snapshot.accountId, 'comment', comment.id);
+        evidenceRefs.push({
+          id: refId,
+          sourceType: 'owned_comment',
+          sourceId: comment.id,
+          label: `重点评论:${this.compact(work.title || work.desc, 24)}`,
+          quote: this.compact(comment.text, 96),
+          reason: '用于归纳用户问题、痛点、购买意向或负反馈,不允许直接复制为方向。',
+        });
+        return `[${refId}] ${this.compact(comment.text, 90)}`;
+      }))
+      .slice(0, 36);
+
+    const candidateBenchmarkSignals = benchmarkEvidence.slice(0, 10).map((item) => {
+      const normalized = this.normalizeBenchmarkEvidence(item);
+      const refId = normalized.sourceId;
+      evidenceRefs.push({
+        id: refId,
+        sourceType: normalized.sourceType,
+        sourceId: normalized.sourceId,
+        label: normalized.title,
+        quote: this.compact(normalized.summary, 96),
+        reason: normalized.reason,
+      });
+      return `[${refId}] ${normalized.title}:${this.compact(normalized.summary, 120)};可借结构:${this.compact(normalized.pattern, 80)}`;
+    });
+
+    const candidatePainSignals = painInsights.slice(0, 12).map((item) => {
+      const refId = item.id;
+      evidenceRefs.push({
+        id: refId,
+        sourceType: 'manual_evidence',
+        sourceId: item.id,
+        label: item.title,
+        quote: this.compact(item.summary || item.representativeComments.join(' / '), 96),
+        reason: `评论痛点:${item.category} / ${item.severity} / ${item.commentCount} 条`,
+      });
+      return `[${refId}] ${item.title}:${this.compact(item.summary, 120)};代表评论:${item.representativeComments.slice(0, 2).map((text) => this.compact(text, 48)).join(' / ')}`;
+    });
+
+    const profileSummary = [
+      `[${profileRefId}] 账号主页公开信息`,
+      `昵称:${this.compact(profile.nickname || account.displayName, 80)}`,
+      `简介:${this.compact(profile.signature || '无简介', 280)}`,
+      `粉丝:${profile.followerCount ?? '未知'}`,
+      `获赞:${profile.totalFavorited ?? '未知'}`,
+      `关注:${profile.followingCount ?? '未知'}`,
+      `作品:${profile.awemeCount ?? snapshot.works.length}`,
+    ].join(';');
+    const accountIntentSummary = [
+      account.intendedTrack ? `想做赛道:${this.compact(account.intendedTrack, 160)}` : '',
+      account.intendedPersona ? `想做人设:${this.compact(account.intendedPersona, 160)}` : '',
+      account.intendedAudience ? `目标受众:${this.compact(account.intendedAudience, 160)}` : '',
+    ].filter(Boolean).join(';') || '用户暂未补充想做赛道、人设和目标受众,需由系统从账号内容中反推。';
+    const activePositioningSummary = activePositioningVersion
+      ? `已生效定位:${this.compact(activePositioningVersion.persona, 120)} / ${this.compact(activePositioningVersion.targetAudience, 180)} / ${this.compact(activePositioningVersion.followReason, 220)}`
+      : '尚无已确认定位版本,本次只能提出待确认定位提案。';
+    const calibrationSummary = this.summarizeCalibrations(calibrationRecords);
+    const retrospectiveSummary = this.summarizeRetrospectivesForStrategy(publishRetrospectives);
+    const rejectedEvidenceIds = [...new Set(calibrationRecords.flatMap((item) => item.rejectedEvidenceIds || []))];
+    const forbiddenTopics = [...new Set(calibrationRecords.flatMap((item) => item.forbiddenTopics || []))];
+    const forbiddenExpressions = [...new Set(calibrationRecords.flatMap((item) => item.forbiddenExpressions || []))];
+    const boundedSignals = this.fitStrategySignalsToBudget({
+      fixedParts: [
+        profileSummary,
+        accountIntentSummary,
+        activePositioningSummary,
+        calibrationSummary.length ? `用户校准:${calibrationSummary.join(';')}` : '',
+        retrospectiveSummary.length ? `发布复盘:${retrospectiveSummary.join(';')}` : '',
+      ].filter(Boolean),
+      topWorks: candidateTopWorks,
+      commentSignals: candidateCommentSignals,
+      benchmarkSignals: candidateBenchmarkSignals,
+      painSignals: candidatePainSignals,
+      maxChars: 6000,
+    });
+    const { topWorks, commentSignals, benchmarkSignals, painSignals, rawTextBudget } = boundedSignals;
+    const includedEvidenceIds = new Set(
+      [...topWorks, ...commentSignals, ...benchmarkSignals, ...painSignals]
+        .flatMap((text) => [...text.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1])),
+    );
+
+    const includedEvidenceRefs = evidenceRefs
+      .filter((item) => includedEvidenceIds.has(item.id))
+      .slice(0, 80)
+      .map((item) => ({
+        ...item,
+        analyzed: false,
+        stage: 'raw_context' as const,
+      }));
+    const workEvidenceBundles = this.workReportService.buildWorkEvidenceBundles(snapshot, includedEvidenceRefs);
+    const workReports = this.selectCompletedWorkReports(snapshot, input.completedWorkReports || []);
+
+    return {
+      accountId: account.id,
+      accountName: profile.nickname || account.displayName,
+      profileSummary,
+      accountIntentSummary,
+      activePositioningSummary,
+      topWorks,
+      commentSignals,
+      benchmarkSignals,
+      painSignals,
+      evidenceRefs: includedEvidenceRefs,
+      workReports,
+      workEvidenceBundles,
+      calibrationSummary,
+      rejectedEvidenceIds,
+      forbiddenTopics,
+      forbiddenExpressions,
+      retrospectiveSummary,
+      rawTextBudget,
+    };
+  }
+
+  buildStrategyPrompt(context: IpAccountStrategyContext): string {
+    return buildAccountStrategyPrompt(context, this.responseSchema());
+  }
+
+  async generateDiagnosis(input: {
+    account: IpMonitoredAccount;
+    snapshot: IpAccountSnapshot;
+    context: IpAccountStrategyContext;
+    localResult: LocalAccountDiagnosisResult;
+  }): Promise<StrategyDiagnosisResult> {
+    const { account, snapshot, context, localResult } = input;
+    const diagnostics: IpAccountStrategyDiagnosticEvent[] = [];
+    this.pushDiagnostic(diagnostics, 'context_built', 'success', '账号分析上下文已生成', this.contextDiagnosticDetail(snapshot, context));
+    const prompt = this.buildStrategyPrompt(context);
+    this.pushDiagnostic(
+      diagnostics,
+      'llm_request',
+      'started',
+      '准备调用 LLM 生成账号策略诊断',
+      `prompt ${prompt.length} 字符 / 已完成作品报告 ${context.workReports.length} 条 / 重点评论 ${this.countComments(snapshot)} 条 / 超时 ${IP_ACCOUNT_STRATEGY_LLM_TIMEOUT_MS}ms`,
+    );
+    try {
+      const raw = await firstValueFrom(this.llm.askWithSystem(
+        IP_ACCOUNT_STRATEGY_SYSTEM_PROMPT,
+        prompt,
+        { temperature: 0.2, max_tokens: IP_ACCOUNT_STRATEGY_LLM_MAX_TOKENS },
+      ).pipe(timeout(IP_ACCOUNT_STRATEGY_LLM_TIMEOUT_MS)));
+      this.pushDiagnostic(diagnostics, 'llm_response', 'success', 'LLM 已返回原始响应', `响应 ${String(raw || '').length} 字符`);
+      let parsed: ParsedIpAccountStrategyResponse;
+      try {
+        parsed = this.parseStrategyResponse(raw);
+      } catch (parseError) {
+        this.pushDiagnostic(diagnostics, 'llm_parse', 'failed', 'LLM 响应解析失败', this.formatLlmFailure(parseError));
+        throw parseError;
+      }
+      this.pushDiagnostic(
+        diagnostics,
+        'llm_parse',
+        'success',
+        'LLM 响应已解析为可用结构',
+        `方向 ${parsed.directions.length} 个 / 作品报告 ${parsed.workReports?.length || 0} 个 / 任务 ${parsed.taskSuggestions.length} 条`,
+      );
+      return this.buildDiagnosisResultFromStrategy({
+        account,
+        snapshot,
+        parsed,
+        localResult,
+        context,
+        sourceMode: 'llm',
+        sourceLabel: 'LLM 证据诊断',
+        diagnostics,
+      });
+    } catch (error: any) {
+      const failureReason = this.formatLlmFailure(error);
+      this.pushDiagnostic(diagnostics, 'llm_fallback', 'fallback', 'LLM 分析失败,已改用本地规则兜底', failureReason);
+      return this.buildDiagnosisResultFromStrategy({
+        account,
+        snapshot,
+        parsed: this.buildLocalFallbackParsed(localResult, context, failureReason),
+        localResult,
+        context,
+        sourceMode: 'local_fallback',
+        sourceLabel: '本地规则诊断(LLM失败)',
+        failureReason,
+        diagnostics,
+      });
+    }
+  }
+
+  parseStrategyResponse(raw: string): ParsedIpAccountStrategyResponse {
+    const jsonText = this.extractJson(raw);
+    const parsed = JSON.parse(jsonText) as ParsedIpAccountStrategyResponse;
+    if (!parsed || typeof parsed !== 'object') throw new Error('策略响应不是 JSON 对象');
+    if (!parsed.diagnosisSummary) throw new Error('策略响应缺少 diagnosisSummary');
+    if (!parsed.positioningProposal) throw new Error('策略响应缺少 positioningProposal');
+    if (!Array.isArray(parsed.directions) || !parsed.directions.length) throw new Error('策略响应缺少 directions');
+    const directionRoles = new Set(parsed.directions.map((direction) => direction.role));
+    for (const role of ['growth', 'trust', 'interaction']) {
+      if (!directionRoles.has(role as IpContentDirection['role'])) {
+        throw new Error(`策略响应缺少 ${role} 方向`);
+      }
+    }
+
+    parsed.evidenceRefs = Array.isArray(parsed.evidenceRefs) ? parsed.evidenceRefs : [];
+    parsed.workReports = Array.isArray(parsed.workReports) ? parsed.workReports : [];
+    parsed.taskSuggestions = Array.isArray(parsed.taskSuggestions) ? parsed.taskSuggestions : [];
+    parsed.appliedCalibrationIds = this.toStringList(parsed.appliedCalibrationIds).slice(0, 8);
+    parsed.appliedCalibrationSummary = this.toStringList(parsed.appliedCalibrationSummary).slice(0, 8);
+    parsed.confidenceLevel = ['high', 'medium', 'low'].includes(String(parsed.confidenceLevel))
+      ? parsed.confidenceLevel
+      : undefined;
+    parsed.evidenceGaps = this.toStringList(parsed.evidenceGaps).slice(0, 8);
+    const evidenceQuotes = parsed.evidenceRefs
+      .map((item) => this.normalizeForCompare(item.quote))
+      .filter((quote) => quote.length >= 12);
+    this.rejectCopiedSourceText(parsed.diagnosisSummary.strongestFollowReason, evidenceQuotes, '用户关注理由');
+    this.rejectCopiedSourceText(parsed.positioningProposal.followReason, evidenceQuotes, '定位价值承诺');
+    if (String(parsed.diagnosisSummary.strongestFollowReason || '').length > 180) {
+      throw new Error('用户关注理由过长,疑似混入文字稿');
+    }
+    if (String(parsed.positioningProposal.followReason || '').length > 180) {
+      throw new Error('定位价值承诺过长,疑似混入文字稿');
+    }
+
+    for (const direction of parsed.directions) {
+      if (!['growth', 'trust', 'interaction'].includes(direction.role)) throw new Error(`未知方向角色:${direction.role}`);
+      this.rejectCopiedSourceText(direction.title, evidenceQuotes, '方向标题');
+      this.rejectCopiedSourceText(direction.purpose, evidenceQuotes, '方向说明');
+      direction.topics = Array.isArray(direction.topics) ? direction.topics : [];
+      for (const topic of direction.topics) {
+        this.rejectCopiedSourceText(topic.title, evidenceQuotes, '选题标题');
+        topic.evidenceRefs = this.toStringList(topic.evidenceRefs);
+        topic.viralPatternRefs = this.toStringList(topic.viralPatternRefs);
+        topic.targetAudience = topic.targetAudience || direction.targetAudience;
+        topic.userProblem = topic.userProblem || direction.purpose || direction.conclusion;
+        topic.fitReason = topic.fitReason || direction.fitReason || direction.conclusion;
+        topic.avoidCopyingRisk = topic.avoidCopyingRisk || direction.risks?.[0] || '只借结构和用户问题,不复制原视频表达。';
+      }
+    }
+
+    parsed.positioningProposal.contentPillars = this.toStringList(parsed.positioningProposal.contentPillars).slice(0, 5);
+    parsed.positioningProposal.expressionStyle = this.toStringList(parsed.positioningProposal.expressionStyle).slice(0, 5);
+    parsed.positioningProposal.boundaries = this.toStringList(parsed.positioningProposal.boundaries).slice(0, 6);
+    parsed.positioningProposal.suitableViralPatterns = this.toStringList(parsed.positioningProposal.suitableViralPatterns).slice(0, 6);
+    parsed.positioningProposal.assumptionsToValidate = this.toStringList(parsed.positioningProposal.assumptionsToValidate).slice(0, 6);
+    parsed.positioningProposal.observeMetrics = this.toStringList(parsed.positioningProposal.observeMetrics).slice(0, 8);
+    return parsed;
+  }
+
+  buildDiagnosisResultFromStrategy(input: {
+    account: IpMonitoredAccount;
+    snapshot: IpAccountSnapshot;
+    parsed: ParsedIpAccountStrategyResponse;
+    localResult: LocalAccountDiagnosisResult;
+    context?: IpAccountStrategyContext;
+    sourceMode: 'llm' | 'local_fallback';
+    sourceLabel: string;
+    failureReason?: string;
+    diagnostics?: IpAccountStrategyDiagnosticEvent[];
+  }): StrategyDiagnosisResult {
+    const { account, snapshot, parsed, localResult, context, sourceMode, sourceLabel, failureReason } = input;
+    const diagnostics = [...(input.diagnostics || [])];
+    const now = new Date().toISOString();
+    const workReports = this.buildWorkReportsForStrategy({
+      now,
+      sourceMode,
+      parsed,
+      context,
+    });
+    const workReportEvidenceRefs = this.workReportService.toWorkReportEvidenceRefs(workReports);
+    parsed.evidenceRefs = this.mergeAnalyzedEvidenceRefs(
+      parsed.evidenceRefs,
+      [...(context?.evidenceRefs || []), ...workReportEvidenceRefs],
+    );
+    const proposal: IpPositioningProposal = {
+      id: `positioning_proposal_${account.id}_${Date.now()}`,
+      accountId: account.id,
+      snapshotId: snapshot.id,
+      status: 'proposed',
+      reason: sourceMode === 'llm'
+        ? `基于账号主页、${snapshot.works.length} 条近期作品、${this.countComments(snapshot)} 条重点评论以及爆款/对标证据生成,需人工确认后生效。`
+        : 'LLM 策略诊断失败,当前为本地规则诊断兜底结果,需人工复核后再生效。',
+      proposedVersion: {
+        accountId: account.id,
+        targetAudience: parsed.positioningProposal.targetAudience || localResult.proposal.proposedVersion.targetAudience,
+        persona: parsed.positioningProposal.persona || localResult.proposal.proposedVersion.persona,
+        followReason: parsed.positioningProposal.followReason || localResult.proposal.proposedVersion.followReason,
+        contentPillars: parsed.positioningProposal.contentPillars.length ? parsed.positioningProposal.contentPillars : localResult.proposal.proposedVersion.contentPillars,
+        expressionStyle: parsed.positioningProposal.expressionStyle.length ? parsed.positioningProposal.expressionStyle : localResult.proposal.proposedVersion.expressionStyle,
+        boundaries: parsed.positioningProposal.boundaries.length ? parsed.positioningProposal.boundaries : localResult.proposal.proposedVersion.boundaries,
+        suitableViralPatterns: parsed.positioningProposal.suitableViralPatterns.length ? parsed.positioningProposal.suitableViralPatterns : localResult.proposal.proposedVersion.suitableViralPatterns,
+        evidenceItemIds: this.collectEvidenceIds(parsed.evidenceRefs, localResult.proposal.evidenceItemIds),
+        assumptionsToValidate: parsed.positioningProposal.assumptionsToValidate.length ? parsed.positioningProposal.assumptionsToValidate : localResult.proposal.proposedVersion.assumptionsToValidate,
+        observeMetrics: parsed.positioningProposal.observeMetrics.length ? parsed.positioningProposal.observeMetrics : localResult.proposal.proposedVersion.observeMetrics,
+      },
+      evidenceItemIds: this.collectEvidenceIds(parsed.evidenceRefs, localResult.proposal.evidenceItemIds),
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    const directions = this.buildDirections(account.id, parsed, localResult.directions, now, workReports);
+    const topics = this.buildTopics(directions, parsed, localResult.topics, workReports);
+    for (const direction of directions) {
+      direction.topicIds = topics.filter((topic) => topic.directionId === direction.id).map((topic) => topic.id);
+    }
+    const tasks = this.buildTasks(account.id, parsed, localResult.tasks, now, workReports);
+    const evidenceRefs = this.applyEvidenceQuality(parsed.evidenceRefs, context);
+    const evidenceGaps = this.buildEvidenceGaps(snapshot, context, parsed);
+    const confidenceLevel = parsed.confidenceLevel || this.inferConfidence(snapshot, evidenceRefs, context);
+    this.pushDiagnostic(
+      diagnostics,
+      'result_built',
+      sourceMode === 'llm' ? 'success' : 'fallback',
+      sourceMode === 'llm' ? '已生成 LLM 策略诊断结果' : '已生成本地兜底策略诊断结果',
+      `置信度 ${confidenceLevel || '待判断'} / 证据引用 ${evidenceRefs.length} 条 / 方向 ${directions.length} 个 / 选题 ${topics.length} 个`,
+    );
+    const appliedCalibrationSummary = parsed.appliedCalibrationSummary?.length
+      ? parsed.appliedCalibrationSummary
+      : context?.calibrationSummary || [];
+    const strategyReport: IpAccountStrategyReport = {
+      id: `strategy_report_${account.id}_${Date.now()}`,
+      accountId: account.id,
+      snapshotId: snapshot.id,
+      positioningProposalId: proposal.id,
+      sourceMode,
+      sourceLabel,
+      diagnosisSummary: parsed.diagnosisSummary,
+      evidenceRefs,
+      workReports,
+      directions: directions.map((direction) => ({
+        id: direction.id,
+        role: direction.role,
+        title: direction.title,
+        conclusion: direction.fitReason || direction.purpose,
+        targetAudience: direction.targetAudience,
+        purpose: direction.purpose,
+        sourceEvidenceIds: direction.sourceEvidenceIds,
+        viralPatternRefs: direction.viralPatternRefs,
+        commentPainRefs: direction.commentPainRefs,
+        cadenceSuggestion: direction.cadenceSuggestion,
+        observeMetrics: direction.observeMetrics,
+        fitReason: direction.fitReason || direction.purpose,
+        risks: direction.risks || [],
+        topics: topics.filter((topic) => topic.directionId === direction.id).map((topic) => ({
+          id: topic.id,
+          directionId: direction.id,
+          title: topic.title,
+          targetAudience: topic.targetAudience || direction.targetAudience,
+          userProblem: topic.userProblem || topic.contentGoal,
+          expectedRole: direction.role,
+          expectedImpact: this.impactFromRole(direction.role),
+          evidenceRefs: topic.evidenceRefs || [],
+          viralPatternRefs: topic.viralPatternRefs || [],
+          avoidCopyingRisk: topic.riskNote || '只借结构和用户问题,不复制原视频表达。',
+          fitReason: topic.fitReason,
+        })),
+      })),
+      taskSuggestions: tasks.map((task) => ({
+        title: task.title,
+        reason: task.reason,
+        type: task.type,
+        column: task.column,
+        growthImpactScore: task.growthImpactScore,
+        relatedEvidenceIds: task.relatedEvidenceIds,
+      })),
+      appliedCalibrationIds: parsed.appliedCalibrationIds?.length
+        ? parsed.appliedCalibrationIds
+        : [],
+      appliedCalibrationSummary,
+      confidenceLevel,
+      evidenceGaps,
+      failureReason,
+      diagnostics,
+      dataScopeSummary: {
+        workCount: snapshot.works.length,
+        commentCount: this.countComments(snapshot),
+        hasPositioningVersion: !!context?.activePositioningSummary && !context.activePositioningSummary.includes('尚无'),
+        calibrationCount: context?.calibrationSummary.length || 0,
+        publishBindingCount: context?.retrospectiveSummary.length || 0,
+      },
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    return {
+      ...localResult,
+      reportMarkdown: this.buildReportMarkdown({ ...parsed, evidenceRefs }, sourceLabel, {
+        confidenceLevel,
+        evidenceGaps,
+        appliedCalibrationSummary,
+      }),
+      proposal,
+      directions,
+      topics,
+      tasks,
+      strategyReport,
+    };
+  }
+
+  buildLocalFallbackParsed(
+    localResult: LocalAccountDiagnosisResult,
+    context?: IpAccountStrategyContext,
+    failureReason = '',
+  ): ParsedIpAccountStrategyResponse {
+    const fallbackEvidenceRefs = (context?.evidenceRefs || []).slice(0, 80);
+    return {
+      diagnosisSummary: {
+        accountSnapshot: failureReason
+          ? `LLM 未返回可用结构化结果,当前保留本地规则诊断。失败原因:${failureReason}`
+          : 'LLM 未返回可用结构化结果,当前保留本地规则诊断。',
+        currentIpGuess: localResult.proposal.proposedVersion.persona,
+        actualAudience: localResult.proposal.proposedVersion.targetAudience,
+        positioningMismatch: '需要人工复核账号实际内容与想做定位是否一致。',
+        strongestFollowReason: localResult.proposal.proposedVersion.followReason,
+        biggestGrowthBlocker: '需要继续补充真实作品、重点评论和对标证据。',
+        highInteractionPattern: localResult.directions.map((item) => item.title).join(' / '),
+        commentPainSummary: localResult.topics.map((item) => item.userProblem || item.contentGoal).filter(Boolean).slice(0, 3).join(' / '),
+        sevenDayPriority: '先确认定位提案,再从三个方向各选 1 条进入脚本和发布包。',
+      },
+      positioningProposal: {
+        targetAudience: localResult.proposal.proposedVersion.targetAudience,
+        persona: localResult.proposal.proposedVersion.persona,
+        followReason: localResult.proposal.proposedVersion.followReason,
+        contentPillars: localResult.proposal.proposedVersion.contentPillars,
+        expressionStyle: localResult.proposal.proposedVersion.expressionStyle,
+        boundaries: localResult.proposal.proposedVersion.boundaries,
+        suitableViralPatterns: localResult.proposal.proposedVersion.suitableViralPatterns,
+        assumptionsToValidate: localResult.proposal.proposedVersion.assumptionsToValidate,
+        observeMetrics: localResult.proposal.proposedVersion.observeMetrics,
+      },
+      evidenceRefs: fallbackEvidenceRefs,
+      workReports: [],
+      directions: localResult.directions.map((direction) => ({
+        role: direction.role,
+        title: direction.title,
+        conclusion: direction.fitReason || direction.purpose,
+        targetAudience: direction.targetAudience,
+        purpose: direction.purpose,
+        sourceEvidenceIds: direction.sourceEvidenceIds,
+        viralPatternRefs: direction.viralPatternRefs,
+        commentPainRefs: direction.commentPainRefs,
+        cadenceSuggestion: direction.cadenceSuggestion,
+        observeMetrics: direction.observeMetrics,
+        fitReason: direction.fitReason || direction.purpose,
+        risks: direction.risks || ['本地规则诊断只作为兜底,需要人工复核。'],
+        topics: localResult.topics.filter((topic) => topic.directionId === direction.id).map((topic) => ({
+          title: topic.title,
+          targetAudience: topic.targetAudience || direction.targetAudience,
+          userProblem: topic.userProblem || topic.contentGoal,
+          expectedImpact: this.impactFromRole(direction.role),
+          evidenceRefs: topic.evidenceRefs || [],
+          viralPatternRefs: topic.viralPatternRefs || [],
+          avoidCopyingRisk: topic.riskNote || '只借结构,不复制原文。',
+          fitReason: topic.fitReason,
+        })),
+      })),
+      taskSuggestions: localResult.tasks.map((task) => ({
+        title: task.title,
+        reason: task.reason,
+        type: task.type,
+        column: task.column,
+        growthImpactScore: task.growthImpactScore,
+        relatedEvidenceIds: task.relatedEvidenceIds,
+      })),
+      evidenceGaps: failureReason ? [`LLM 分析失败:${failureReason}`] : ['LLM 未返回可用结构化结果'],
+    };
+  }
+
+  private pushDiagnostic(
+    diagnostics: IpAccountStrategyDiagnosticEvent[],
+    stage: IpAccountStrategyDiagnosticEvent['stage'],
+    status: IpAccountStrategyDiagnosticEvent['status'],
+    message: string,
+    detail = '',
+  ): void {
+    diagnostics.push({
+      id: `strategy_diag_${stage}_${diagnostics.length + 1}_${Date.now()}`,
+      stage,
+      status,
+      message,
+      detail: this.compact(detail, 260),
+      createdAt: new Date().toISOString(),
+    });
+  }
+
+  private contextDiagnosticDetail(snapshot: IpAccountSnapshot, context: IpAccountStrategyContext): string {
+    const reportCount = context.workReports.length;
+    const transcriptCount = context.workEvidenceBundles.length;
+    const missingTranscriptCount = this.workReportService.missingTranscriptWorkIds(snapshot).length;
+    return [
+      `作品 ${snapshot.works.length} 条`,
+      `已完成作品报告 ${reportCount} 条`,
+      `可补作品报告的文字稿 ${transcriptCount} 条`,
+      `缺失文字稿 ${missingTranscriptCount} 条`,
+      `重点评论 ${this.countComments(snapshot)} 条`,
+      `证据引用 ${context.evidenceRefs.length} 条`,
+      `上下文预算 ${context.rawTextBudget} 字符`,
+    ].join(' / ');
+  }
+
+  private formatLlmFailure(error: unknown): string {
+    const raw = String((error as any)?.message || error || '未知错误').replace(/\s+/g, ' ').trim();
+    if (!raw) return '未知错误';
+    return this.compact(raw, 180);
+  }
+
+  private responseSchema(): object {
+    return {
+      diagnosisSummary: {
+        accountSnapshot: '账号当前状态一句话',
+        currentIpGuess: '当前账号像什么 IP',
+        actualAudience: '最近内容实际吸引的人群',
+        positioningMismatch: '想做定位和真实内容的偏差',
+        strongestFollowReason: '用户最可能关注它的理由',
+        biggestGrowthBlocker: '最影响涨粉/播放/互动的障碍',
+        highInteractionPattern: '高互动作品共性',
+        commentPainSummary: '评论区用户痛点',
+        sevenDayPriority: '未来 7 天优先动作',
+      },
+      positioningProposal: {
+        targetAudience: '明确目标人群',
+        persona: '建议账号角色',
+        followReason: '一句账号价值承诺',
+        contentPillars: ['内容支柱1', '内容支柱2', '内容支柱3'],
+        expressionStyle: ['表达风格1', '表达风格2'],
+        boundaries: ['明确不做什么'],
+        suitableViralPatterns: ['适合借鉴的爆款结构'],
+        assumptionsToValidate: ['待验证假设'],
+        observeMetrics: ['观察指标'],
+      },
+      evidenceRefs: [
+        {
+          id: '必须使用账号证据上下文 evidenceRefs 中已有的 id,不能自造',
+          sourceType: 'owned_work',
+          sourceId: '必须使用账号证据上下文 evidenceRefs 中已有的 sourceId',
+          label: '证据角色,例如:高互动教程作品',
+          quote: '归纳后的证据信号,不能复制作品标题/简介/评论原文',
+          reason: '它支持了哪个运营判断,以及为什么重要',
+        },
+      ],
+      workReports: [
+        {
+          id: 'work_report_<workId>',
+          workId: '必须来自账号上下文 workReports[].workId;账号级阶段不接收完整视频文字稿',
+          reports: [
+            {
+              kind: 'structure | operation',
+              headline: '不复制标题的报告标题',
+              summary: '基于视频文字稿的作品级归纳摘要',
+              evidenceSignals: ['文字稿内容信号、数据、评论聚合信号'],
+              strategyJudgment: '基于文字稿和账号上下文的运营判断',
+              supportingEvidenceIds: ['可包含 workEvidenceRefId 和评论 evidence id'],
+              confidence: 'high | medium | low',
+              gaps: ['仍缺什么证据'],
+            },
+          ],
+        },
+      ],
+      appliedCalibrationIds: ['采用的用户校准记录 id'],
+      appliedCalibrationSummary: ['本轮实际采用了哪条用户校准'],
+      confidenceLevel: 'high | medium | low',
+      evidenceGaps: ['仍然缺少的验证材料或复盘数据'],
+      directions: [
+        {
+          role: 'growth',
+          title: '涨粉破圈方向名称',
+          conclusion: '为什么这是一个方向',
+          targetAudience: '服务人群',
+          purpose: '方向作用',
+          sourceEvidenceIds: ['ev_1'],
+          viralPatternRefs: ['爆款结构'],
+          commentPainRefs: ['ev_2'],
+          cadenceSuggestion: '每周 2 条',
+          observeMetrics: ['播放', '新粉'],
+          fitReason: '为什么适合当前账号',
+          risks: ['避免照搬点'],
+          topics: [
+            {
+              title: '可拍选题',
+              targetAudience: '服务人群',
+              userProblem: '解决的问题',
+              expectedImpact: '涨粉',
+              evidenceRefs: ['ev_1'],
+              viralPatternRefs: ['爆款结构'],
+              avoidCopyingRisk: '不能照搬之处',
+              fitReason: '适配理由',
+            },
+          ],
+        },
+      ],
+      taskSuggestions: [
+        { title: '任务标题', reason: '任务理由', type: 'direction', column: 'today', growthImpactScore: 90, relatedEvidenceIds: ['ev_1'] },
+      ],
+    };
+  }
+
+  private normalizeBenchmarkEvidence(item: IpBenchmarkEvidence | IpEvidenceItem): {
+    sourceType: IpAccountStrategySourceType;
+    sourceId: string;
+    title: string;
+    summary: string;
+    pattern: string;
+    reason: string;
+  } {
+    if ('reusableFrame' in item) {
+      return {
+        sourceType: item.source === 'viral_analysis' ? 'benchmark_work' : 'manual_evidence',
+        sourceId: item.id,
+        title: item.title,
+        summary: item.summary || item.migrationSuggestion || item.hookPattern,
+        pattern: item.reusableFrame || item.hookPattern,
+        reason: item.nonCopyableRisk || '用于判断可借结构和不可复制风险。',
+      };
+    }
+    return {
+      sourceType: item.kind === 'comment' ? 'benchmark_comment' : item.kind === 'competitor_post' ? 'benchmark_work' : 'manual_evidence',
+      sourceId: item.id,
+      title: item.title,
+      summary: item.summary || item.rawText,
+      pattern: item.tags.join(' / '),
+      reason: item.evidenceRefs.join(' / ') || '用于补充对标/爆款证据。',
+    };
+  }
+
+  private mergeAnalyzedEvidenceRefs(
+    analyzedRefs: IpAccountStrategyEvidenceRef[],
+    contextRefs: IpAccountStrategyEvidenceRef[],
+  ): IpAccountStrategyEvidenceRef[] {
+    if (!contextRefs.length) return [];
+    const contextById = new Map(contextRefs.map((item) => [item.id, item]));
+    const contextBySourceId = new Map(contextRefs.map((item) => [item.sourceId, item]));
+    const contextByLabel = new Map(contextRefs.map((item) => [this.normalizeForCompare(item.label), item]));
+    const merged: IpAccountStrategyEvidenceRef[] = [];
+    const used = new Set<string>();
+
+    for (const ref of analyzedRefs || []) {
+      const context = contextById.get(String(ref?.id || ''))
+        || contextBySourceId.get(String(ref?.sourceId || ''))
+        || contextByLabel.get(this.normalizeForCompare(ref?.label || ''));
+      if (!context || used.has(context.id)) continue;
+      const quote = this.cleanAnalyzedEvidenceText(ref.quote, context.quote, 140);
+      const reason = this.cleanAnalyzedEvidenceText(ref.reason, context.reason, 180);
+      merged.push({
+        ...context,
+        label: this.compact(String(ref.label || context.label || '').trim() || context.label, 48),
+        quote: quote || context.quote,
+        reason: reason || context.reason,
+        analyzed: context.stage === 'work_report' ? context.analyzed : true,
+        stage: context.stage === 'work_report' ? 'work_report' : 'strategy_judgment',
+      });
+      used.add(context.id);
+    }
+
+    for (const context of contextRefs) {
+      if (used.has(context.id)) continue;
+      if (context.stage === 'raw_context') {
+        merged.push({
+          ...context,
+          analyzed: false,
+          stage: 'raw_context',
+          reason: context.sourceType === 'owned_comment'
+            ? '原始评论样本,仅作为作品报告的支撑证据。'
+            : context.reason,
+          gaps: [...new Set([...(context.gaps || []), '原始采集证据,未经过 LLM 单条判断'])],
+        });
+        continue;
+      }
+      merged.push(context);
+    }
+    return merged.slice(0, 80);
+  }
+
+  private buildWorkReportsForStrategy(input: {
+    now: string;
+    sourceMode: 'llm' | 'local_fallback';
+    parsed: ParsedIpAccountStrategyResponse;
+    context?: IpAccountStrategyContext;
+  }): IpAccountWorkStrategyReport[] {
+    if (input.context?.workReports?.length) return input.context.workReports.slice(0, 8);
+    const bundles = input.context?.workEvidenceBundles || [];
+    if (!bundles.length) return [];
+    if (input.sourceMode === 'llm') {
+      const analyzedReports = this.workReportService.normalizeParsedWorkReports({
+        now: input.now,
+        sourceMode: 'llm',
+        parsedReports: input.parsed.workReports || [],
+        bundles,
+      });
+      if (analyzedReports.length) return analyzedReports;
+    }
+    return this.workReportService.buildLocalFallbackWorkReports({
+      now: input.now,
+      bundles,
+    });
+  }
+
+  private selectCompletedWorkReports(
+    snapshot: IpAccountSnapshot,
+    reports: IpAccountWorkStrategyReport[],
+  ): IpAccountWorkStrategyReport[] {
+    const workIds = new Set(snapshot.works.map((work) => work.id));
+    const seen = new Set<string>();
+    return reports
+      .filter((report) => workIds.has(report.workId) && report.reports?.length)
+      .filter((report) => {
+        const key = report.id || report.workId;
+        if (seen.has(key)) return false;
+        seen.add(key);
+        return true;
+      })
+      .slice(0, 8);
+  }
+
+  private cleanAnalyzedEvidenceText(value: unknown, fallback: string, maxLength: number): string {
+    const text = this.compact(String(value || '').trim(), maxLength);
+    if (!text) return '';
+    const normalized = this.normalizeForCompare(text);
+    const fallbackNormalized = this.normalizeForCompare(fallback);
+    if (normalized.length >= 12 && fallbackNormalized.includes(normalized)) return '';
+    return text;
+  }
+
+  private buildDirections(
+    accountId: string,
+    parsed: ParsedIpAccountStrategyResponse,
+    fallbackDirections: IpContentDirection[],
+    now: string,
+    workReports: IpAccountWorkStrategyReport[],
+  ): IpContentDirection[] {
+    const roleOrder: IpContentDirection['role'][] = ['growth', 'trust', 'interaction'];
+    const reportEvidenceIds = parsed.evidenceRefs.map((item) => item.id);
+    return roleOrder.map((role) => {
+      const parsedDirection = parsed.directions.find((direction) => direction.role === role);
+      const fallback = fallbackDirections.find((direction) => direction.role === role) || fallbackDirections[0];
+      const directionId = fallback?.id || `direction_${accountId}_${role}`;
+      return {
+        id: directionId,
+        accountId,
+        positioningVersionId: fallback?.positioningVersionId || 'pending_positioning_version',
+        role,
+        title: this.compact(parsedDirection?.title || fallback?.title || this.defaultDirectionTitle(role), 36),
+        targetAudience: parsedDirection?.targetAudience || fallback?.targetAudience || parsed.positioningProposal.targetAudience,
+        purpose: parsedDirection?.purpose || parsedDirection?.conclusion || fallback?.purpose || '',
+        sourceEvidenceIds: this.reportScopeEvidenceIds(
+          parsedDirection?.sourceEvidenceIds,
+          reportEvidenceIds,
+          workReports,
+          fallback?.sourceEvidenceIds || reportEvidenceIds,
+        ).slice(0, 12),
+        viralPatternRefs: this.toStringList(parsedDirection?.viralPatternRefs).slice(0, 8),
+        commentPainRefs: this.reportScopeEvidenceIds(
+          parsedDirection?.commentPainRefs,
+          reportEvidenceIds,
+          workReports,
+          fallback?.commentPainRefs || [],
+        ).slice(0, 12),
+        topicIds: [],
+        cadenceSuggestion: parsedDirection?.cadenceSuggestion || fallback?.cadenceSuggestion || (role === 'growth' ? '每周 2-3 条' : '每周 1-2 条'),
+        observeMetrics: this.toStringList(parsedDirection?.observeMetrics).length ? this.toStringList(parsedDirection?.observeMetrics) : (fallback?.observeMetrics || this.defaultMetrics(role)),
+        fitReason: parsedDirection?.fitReason || parsedDirection?.conclusion || fallback?.fitReason || '',
+        risks: this.toStringList(parsedDirection?.risks).length ? this.toStringList(parsedDirection?.risks).slice(0, 4) : (fallback?.risks || ['只借结构和用户问题,不复制原视频表达。']),
+        createdAt: fallback?.createdAt || now,
+        updatedAt: now,
+      };
+    });
+  }
+
+  private buildTopics(
+    directions: IpContentDirection[],
+    parsed: ParsedIpAccountStrategyResponse,
+    fallbackTopics: IpTopic[],
+    workReports: IpAccountWorkStrategyReport[],
+  ): IpTopic[] {
+    const reportEvidenceIds = parsed.evidenceRefs.map((item) => item.id);
+    return directions.flatMap((direction) => {
+      const parsedDirection = parsed.directions.find((item) => item.role === direction.role);
+      const parsedTopics = (parsedDirection?.topics || []).slice(0, 4).map((topic, index) => ({
+        id: `account_topic_${direction.accountId}_${direction.role}_${index + 1}`,
+        title: this.compact(topic.title, 52),
+        directionId: direction.id,
+        column: direction.title,
+        trafficLayer: direction.role === 'growth' ? 'broad' as const : direction.role === 'trust' ? 'conversion' as const : 'vertical' as const,
+        contentGoal: `${topic.expectedImpact || this.impactFromRole(direction.role)}:${topic.fitReason || direction.fitReason || direction.purpose}`,
+        targetAudience: topic.targetAudience || direction.targetAudience,
+        userProblem: topic.userProblem,
+        evidenceRefs: this.reportScopeEvidenceIds(
+          topic.evidenceRefs,
+          reportEvidenceIds,
+          workReports,
+          direction.sourceEvidenceIds,
+        ),
+        viralPatternRefs: this.toStringList(topic.viralPatternRefs),
+        expectedRole: direction.role,
+        priority: index === 0 ? 'high' as const : index === 1 ? 'medium' as const : 'low' as const,
+        fitReason: topic.fitReason || direction.fitReason || '',
+        source: '账号策略诊断',
+        requiredMaterials: direction.role === 'trust' ? ['真实过程、截图或案例证据'] : ['账号真实画面或评论问题'],
+        scriptStatus: 'outline' as const,
+        riskNote: topic.avoidCopyingRisk || direction.risks?.[0] || '只借结构和用户问题,不复制原视频表达。',
+        qualityLevel: index === 0 ? 'make_now' as const : index === 1 ? 'polish_first' as const : 'needs_material' as const,
+        productionReadinessScore: index === 0 ? 84 : index === 1 ? 74 : 66,
+      }));
+      if (parsedTopics.length) return parsedTopics;
+
+      const fallbackForDirection = fallbackTopics
+        .filter((topic) => topic.directionId === direction.id || topic.expectedRole === direction.role)
+        .slice(0, 3)
+        .map((topic, index) => ({
+          ...topic,
+          id: topic.id || `account_topic_${direction.accountId}_${direction.role}_${index + 1}`,
+          directionId: direction.id,
+          column: topic.column || direction.title,
+          expectedRole: topic.expectedRole || direction.role,
+          targetAudience: topic.targetAudience || direction.targetAudience,
+          userProblem: topic.userProblem || this.defaultTopicUserProblemForRole(direction.role),
+          evidenceRefs: topic.evidenceRefs?.length ? topic.evidenceRefs : direction.sourceEvidenceIds,
+          viralPatternRefs: topic.viralPatternRefs?.length ? topic.viralPatternRefs : direction.viralPatternRefs,
+          riskNote: topic.riskNote || direction.risks?.[0] || '只借结构和用户问题,不复制原视频表达。',
+        }));
+      if (fallbackForDirection.length) return fallbackForDirection;
+
+      return [this.buildFallbackTopicForDirection(direction)];
+    });
+  }
+
+  private buildTasks(
+    accountId: string,
+    parsed: ParsedIpAccountStrategyResponse,
+    fallbackTasks: IpOperationTask[],
+    now: string,
+    workReports: IpAccountWorkStrategyReport[],
+  ): IpOperationTask[] {
+    const suggestions = parsed.taskSuggestions.slice(0, 8);
+    if (!suggestions.length) return fallbackTasks;
+    const reportEvidenceIds = parsed.evidenceRefs.map((item) => item.id);
+    return suggestions.map((item, index) => ({
+      id: `task_${accountId}_strategy_${index + 1}`,
+      accountId,
+      column: ['today', 'this_week', 'later', 'done'].includes(item.column) ? item.column : 'this_week',
+      type: item.type || 'direction',
+      title: this.compact(item.title, 40),
+      reason: this.compact(item.reason, 120),
+      growthImpactScore: Math.max(0, Math.min(100, Math.round(Number(item.growthImpactScore) || 70))),
+      relatedEvidenceIds: this.reportScopeEvidenceIds(
+        item.relatedEvidenceIds,
+        reportEvidenceIds,
+        workReports,
+        fallbackTasks[index]?.relatedEvidenceIds || reportEvidenceIds.slice(0, 3),
+      ),
+      createdAt: now,
+      updatedAt: now,
+    }));
+  }
+
+  private summarizeCalibrations(records: IpAccountCalibrationRecord[]): string[] {
+    return records.map((record) => [
+      record.accuracy === 'accurate' ? '用户认为诊断准确' : record.accuracy === 'partial' ? '用户认为诊断部分准确' : '用户认为诊断不准确',
+      record.targetAudienceNotes ? `目标受众:${this.compact(record.targetAudienceNotes, 80)}` : '',
+      record.positioningNotes ? `定位修正:${this.compact(record.positioningNotes, 100)}` : '',
+      record.personaNotes ? `人设修正:${this.compact(record.personaNotes, 80)}` : '',
+      record.forbiddenTopics?.length ? `不要做:${record.forbiddenTopics.join('、')}` : '',
+      record.forbiddenExpressions?.length ? `不要这样表达:${record.forbiddenExpressions.join('、')}` : '',
+      record.operatorExperienceNotes ? `经验补充:${this.compact(record.operatorExperienceNotes, 100)}` : '',
+    ].filter(Boolean).join(';')).filter(Boolean);
+  }
+
+  private summarizeRetrospectives(records: IpPublishRetrospectiveRecord[]): string[] {
+    return records.map((record) => [
+      `结果:${record.outcome}`,
+      record.expectedGoal?.length ? `预期:${record.expectedGoal.join('、')}` : '',
+      record.actualSignals?.length ? `实际:${record.actualSignals.join('、')}` : '',
+      record.diagnosis ? `判断:${this.compact(record.diagnosis, 120)}` : '',
+      `下一步:${record.nextAction}`,
+    ].filter(Boolean).join(';')).filter(Boolean);
+  }
+
+  private summarizeRetrospectivesForStrategy(records: IpPublishRetrospectiveRecord[]): string[] {
+    return records.map((record) => {
+      const insight = record.insight;
+      const causeSignals = (insight?.causeSignals || [])
+        .slice(0, 4)
+        .map((item) => `${item.label}:${this.compact(item.judgment, 70)}`);
+      const directionAdjustments = (insight?.directionAdjustments || [])
+        .slice(0, 4)
+        .map((item) => `${item.directionTitle}:${item.action}:${this.compact(item.reason, 70)}`);
+      const nextTopicSignals = (insight?.nextTopicSignals || [])
+        .slice(0, 4)
+        .map((item) => `${item.title}:${item.source}:${this.compact(item.userProblem, 60)}`);
+      const scriptConstraints = (insight?.scriptConstraints || [])
+        .slice(0, 4)
+        .map((item) => `${item.type}:${this.compact(item.instruction, 70)}${item.avoid ? ` / 避免:${this.compact(item.avoid, 50)}` : ''}`);
+      return [
+        `发布结果:${record.outcome}`,
+        record.sourceMode ? `复盘来源:${record.sourceMode}` : '',
+        record.expectedGoal?.length ? `发布前目标:${record.expectedGoal.join(' / ')}` : '',
+        record.actualSignals?.length ? `发布后信号:${record.actualSignals.join(' / ')}` : '',
+        record.diagnosis ? `复盘判断:${this.compact(record.diagnosis, 140)}` : '',
+        insight?.positioningImpact ? `定位影响:${this.compact(insight.positioningImpact, 120)}` : '',
+        insight?.nextRoundBrief ? `下一轮简报:${this.compact(insight.nextRoundBrief, 160)}` : '',
+        causeSignals.length ? `归因:${causeSignals.join(' | ')}` : '',
+        directionAdjustments.length ? `方向调整:${directionAdjustments.join(' | ')}` : '',
+        nextTopicSignals.length ? `下一轮选题信号:${nextTopicSignals.join(' | ')}` : '',
+        scriptConstraints.length ? `脚本约束:${scriptConstraints.join(' | ')}` : '',
+        `下一步:${record.nextAction}`,
+      ].filter(Boolean).join(';');
+    }).filter(Boolean);
+  }
+
+  private applyEvidenceQuality(
+    evidenceRefs: IpAccountStrategyEvidenceRef[],
+    context?: IpAccountStrategyContext,
+  ): IpAccountStrategyEvidenceRef[] {
+    const rejected = new Set(context?.rejectedEvidenceIds || []);
+    return evidenceRefs.map((item) => {
+      if (item.stage === 'raw_context') {
+        return {
+          ...item,
+          analyzed: false,
+          confidence: rejected.has(item.id) || rejected.has(item.sourceId) ? 'low' : item.confidence || 'medium',
+          gaps: [...new Set([...(item.gaps || []), '原始采集证据,不代表独立运营判断'])],
+        };
+      }
+      const gaps: string[] = [];
+      if (rejected.has(item.id) || rejected.has(item.sourceId)) gaps.push('用户已标记该证据不应作为强依据');
+      if (!item.reason || item.reason === item.quote) gaps.push('缺少独立运营判断');
+      if (item.sourceType === 'owned_work' && !(context?.commentSignals || []).length) gaps.push('缺少评论样本验证');
+      const confidence = rejected.has(item.id) || rejected.has(item.sourceId)
+        ? 'low'
+        : gaps.length
+          ? 'medium'
+          : 'high';
+      return {
+        ...item,
+        analyzed: !!item.reason && item.reason !== item.quote,
+        confidence,
+        gaps: [...new Set([...(item.gaps || []), ...gaps])],
+      };
+    });
+  }
+
+  private buildEvidenceGaps(
+    snapshot: IpAccountSnapshot,
+    context: IpAccountStrategyContext | undefined,
+    parsed: ParsedIpAccountStrategyResponse,
+  ): string[] {
+    const gaps: string[] = [];
+    if (!snapshot.works.length) gaps.push('缺少近期作品样本');
+    const missingTranscriptCount = snapshot.works.filter((work) => !String(work.transcript?.text || '').trim()).length;
+    if (missingTranscriptCount) gaps.push(`${missingTranscriptCount} 条作品缺少视频文字稿,不能生成完整作品分析`);
+    if (snapshot.works.length && !context?.workReports.length) gaps.push('缺少基于视频文字稿的作品级报告');
+    if (this.countComments(snapshot) < 10) gaps.push('重点评论样本偏少,互动/痛点判断需要继续验证');
+    if (!context?.calibrationSummary.length) gaps.push('尚未保存用户校准,下一轮分析缺少人工判断约束');
+    if (!context?.retrospectiveSummary.length) gaps.push('尚未绑定发布后表现,缺少真实复盘数据');
+    if (!parsed.evidenceRefs.length) gaps.push('诊断未形成可追溯证据引用');
+    return [...new Set([...(parsed.evidenceGaps || []), ...gaps])].slice(0, 8);
+  }
+
+  private inferConfidence(
+    snapshot: IpAccountSnapshot,
+    evidenceRefs: IpAccountStrategyEvidenceRef[],
+    context?: IpAccountStrategyContext,
+  ): IpAccountStrategyReport['confidenceLevel'] {
+    const commentCount = this.countComments(snapshot);
+    const hasTranscriptBackedWork = !!context?.workReports.length;
+    const hasAnalyzed = evidenceRefs.some((item) => item.analyzed && item.confidence !== 'low');
+    const hasCalibration = !!context?.calibrationSummary.length;
+    const hasRetrospective = !!context?.retrospectiveSummary.length;
+    if (snapshot.works.length >= 8 && commentCount >= 30 && hasTranscriptBackedWork && hasAnalyzed && hasCalibration && hasRetrospective) return 'high';
+    if (snapshot.works.length >= 3 && commentCount >= 5 && hasTranscriptBackedWork && hasAnalyzed) return 'medium';
+    return 'low';
+  }
+
+  private buildReportMarkdown(
+    parsed: ParsedIpAccountStrategyResponse,
+    sourceLabel: string,
+    meta?: {
+      confidenceLevel?: IpAccountStrategyReport['confidenceLevel'];
+      evidenceGaps?: string[];
+      appliedCalibrationSummary?: string[];
+    },
+  ): string {
+    const summary = parsed.diagnosisSummary;
+    return [
+      '# 账号策略诊断报告',
+      '',
+      `诊断来源:${sourceLabel}`,
+      `置信度:${meta?.confidenceLevel || '待判断'}`,
+      meta?.appliedCalibrationSummary?.length ? `已采用用户校准:${meta.appliedCalibrationSummary.join(';')}` : '已采用用户校准:暂无',
+      meta?.evidenceGaps?.length ? `仍缺:${meta.evidenceGaps.join(';')}` : '仍缺:暂无关键缺口',
+      '',
+      '## 当前账号像什么 IP',
+      summary.currentIpGuess,
+      '',
+      '## 实际吸引的人群',
+      summary.actualAudience,
+      '',
+      '## 用户为什么值得持续关注',
+      summary.strongestFollowReason,
+      '',
+      '## 最大增长障碍',
+      summary.biggestGrowthBlocker,
+      '',
+      '## 高互动作品共性',
+      summary.highInteractionPattern,
+      '',
+      '## 评论区痛点',
+      summary.commentPainSummary,
+      '',
+      '## 未来 7 天优先动作',
+      summary.sevenDayPriority,
+    ].join('\n');
+  }
+
+  private extractJson(raw: string): string {
+    const text = String(raw || '').trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim();
+    if (text.startsWith('{') && text.endsWith('}')) return text;
+    const start = text.indexOf('{');
+    const end = text.lastIndexOf('}');
+    if (start < 0 || end <= start) throw new Error('策略响应中没有可解析 JSON');
+    return text.slice(start, end + 1);
+  }
+
+  private rejectCopiedSourceText(value: string, evidenceQuotes: string[], label: string): void {
+    const normalized = this.normalizeForCompare(value);
+    if (normalized.length < 18) return;
+    for (const quote of evidenceQuotes) {
+      if (!quote) continue;
+      const quoteHead = quote.slice(0, Math.min(30, quote.length));
+      if (quote.includes(normalized) || normalized.includes(quoteHead)) {
+        throw new Error(`${label}疑似复制原始证据:${value}`);
+      }
+    }
+  }
+
+  private normalizeForCompare(value: string): string {
+    return String(value || '').replace(/\s+/g, '').replace(/[,。!?、:;,.!?;:"'“”‘’()()\[\]【】]/g, '').trim();
+  }
+
+  private collectEvidenceIds(evidenceRefs: IpAccountStrategyEvidenceRef[], fallback: string[]): string[] {
+    const values = [...evidenceRefs.map((item) => item.id), ...fallback].filter(Boolean);
+    return [...new Set(values)].slice(0, 20);
+  }
+
+  private validEvidenceIds(value: unknown, allowed: string[], fallback: string[]): string[] {
+    const allowedSet = new Set(allowed);
+    if (!allowedSet.size) {
+      const localRefs = [...this.toStringList(value), ...this.toStringList(fallback)];
+      return [...new Set(localRefs)].slice(0, 12);
+    }
+    const valid = this.toStringList(value).filter((id) => allowedSet.has(id));
+    const fallbackValid = this.toStringList(fallback).filter((id) => allowedSet.has(id));
+    return [...new Set(valid.length ? valid : fallbackValid.length ? fallbackValid : allowed.slice(0, 3))];
+  }
+
+  private reportScopeEvidenceIds(
+    value: unknown,
+    allowed: string[],
+    workReports: IpAccountWorkStrategyReport[],
+    fallback: string[],
+  ): string[] {
+    const allowedSet = new Set(allowed);
+    const aliasToReportId = new Map<string, string>();
+    for (const report of workReports) {
+      aliasToReportId.set(report.id, report.id);
+      aliasToReportId.set(report.workId, report.id);
+      aliasToReportId.set(report.sourceEvidenceId, report.id);
+      for (const commentId of report.supportingCommentEvidenceIds) {
+        aliasToReportId.set(commentId, report.id);
+      }
+      for (const block of report.reports) {
+        for (const evidenceId of block.supportingEvidenceIds || []) {
+          aliasToReportId.set(evidenceId, report.id);
+        }
+      }
+    }
+
+    const normalize = (ids: string[]) => ids
+      .map((id) => aliasToReportId.get(id) || id)
+      .filter((id) => !allowedSet.size || allowedSet.has(id));
+    const valid = normalize(this.toStringList(value));
+    const fallbackValid = normalize(this.toStringList(fallback));
+    if (valid.length) return [...new Set(valid)].slice(0, 12);
+    if (fallbackValid.length) return [...new Set(fallbackValid)].slice(0, 12);
+    return allowed.filter((id) => id.startsWith('work_report_')).slice(0, 3);
+  }
+
+  private countComments(snapshot: IpAccountSnapshot): number {
+    return snapshot.works.reduce((sum, work) => sum + work.comments.length, 0);
+  }
+
+  private defaultDirectionTitle(role: IpContentDirection['role']): string {
+    return { growth: '涨粉破圈方向', trust: '信任建立方向', interaction: '互动共鸣方向' }[role];
+  }
+
+  private buildFallbackTopicForDirection(direction: IpContentDirection): IpTopic {
+    return {
+      id: `account_topic_${direction.accountId}_${direction.role}_fallback_1`,
+      title: this.defaultTopicTitleForDirection(direction),
+      directionId: direction.id,
+      column: direction.title,
+      trafficLayer: direction.role === 'growth' ? 'broad' : direction.role === 'trust' ? 'conversion' : 'vertical',
+      contentGoal: `${this.impactFromRole(direction.role)}:${direction.purpose || direction.fitReason || direction.title}`,
+      targetAudience: direction.targetAudience,
+      userProblem: this.defaultTopicUserProblemForRole(direction.role),
+      evidenceRefs: direction.sourceEvidenceIds || [],
+      viralPatternRefs: direction.viralPatternRefs || [],
+      expectedRole: direction.role,
+      priority: 'medium',
+      fitReason: direction.fitReason || direction.purpose || '',
+      source: '账号策略诊断',
+      requiredMaterials: direction.role === 'trust'
+        ? ['真实过程、截图或案例证据']
+        : ['账号真实画面或评论问题'],
+      scriptStatus: 'outline',
+      riskNote: direction.risks?.[0] || '只借结构和用户问题,不复制原视频表达。',
+      qualityLevel: 'polish_first',
+      productionReadinessScore: 64,
+    };
+  }
+
+  private defaultTopicTitleForDirection(direction: IpContentDirection): string {
+    if (direction.role === 'growth') return `${direction.title}:用一个高频误区切入`;
+    if (direction.role === 'trust') return `${direction.title}:用一个真实案例建立信任`;
+    return `${direction.title}:用一个评论问题引发互动`;
+  }
+
+  private defaultTopicUserProblemForRole(role: IpContentDirection['role']): string {
+    if (role === 'growth') return '用户还不知道为什么要关注这个账号';
+    if (role === 'trust') return '用户相信方向,但缺少可信案例和判断标准';
+    return '用户有具体问题,但还没有被集中回应';
+  }
+
+  private defaultMetrics(role: IpContentDirection['role']): string[] {
+    return role === 'growth' ? ['播放量', '分享数', '新粉'] : role === 'trust' ? ['收藏数', '关注率', '咨询数'] : ['评论数', '评论率', '高频问题数'];
+  }
+
+  private impactFromRole(role: IpContentDirection['role']): IpAccountStrategyImpact {
+    return role === 'growth' ? '涨粉' : role === 'trust' ? '信任' : '互动';
+  }
+
+  private toStringList(value: unknown): string[] {
+    if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean);
+    if (typeof value === 'string' && value.trim()) return value.split(/[,,、\n]/).map((item) => item.trim()).filter(Boolean);
+    return [];
+  }
+
+  private compact(value: string, maxLength: number): string {
+    const normalized = String(value || '').replace(/\s+/g, ' ').trim();
+    if (!normalized) return '';
+    return normalized.length > maxLength ? `${normalized.slice(0, Math.max(1, maxLength - 1))}…` : normalized;
+  }
+
+  private fitStrategySignalsToBudget(input: {
+    fixedParts: string[];
+    topWorks: string[];
+    commentSignals: string[];
+    benchmarkSignals: string[];
+    painSignals: string[];
+    maxChars: number;
+  }): {
+    topWorks: string[];
+    commentSignals: string[];
+    benchmarkSignals: string[];
+    painSignals: string[];
+    rawTextBudget: number;
+  } {
+    type SignalKey = 'topWorks' | 'commentSignals' | 'benchmarkSignals' | 'painSignals';
+    const keys: SignalKey[] = ['topWorks', 'commentSignals', 'commentSignals', 'benchmarkSignals', 'painSignals'];
+    const source: Record<SignalKey, string[]> = {
+      topWorks: input.topWorks,
+      commentSignals: input.commentSignals,
+      benchmarkSignals: input.benchmarkSignals,
+      painSignals: input.painSignals,
+    };
+    const selected: Record<SignalKey, string[]> = {
+      topWorks: [],
+      commentSignals: [],
+      benchmarkSignals: [],
+      painSignals: [],
+    };
+    const indexes: Record<SignalKey, number> = {
+      topWorks: 0,
+      commentSignals: 0,
+      benchmarkSignals: 0,
+      painSignals: 0,
+    };
+    let used = input.fixedParts.join('\n').length;
+    let remainingCandidates = true;
+    while (remainingCandidates) {
+      remainingCandidates = false;
+      for (const key of keys) {
+        const index = indexes[key];
+        if (index >= source[key].length) continue;
+        remainingCandidates = true;
+        const candidate = source[key][index];
+        indexes[key] += 1;
+        const separatorLength = used > 0 ? 1 : 0;
+        if (used + separatorLength + candidate.length > input.maxChars) continue;
+        selected[key].push(candidate);
+        used += separatorLength + candidate.length;
+      }
+    }
+    return {
+      ...selected,
+      rawTextBudget: used,
+    };
+  }
+}

+ 235 - 0
src/app/services/ip-account-transcript-orchestrator.service.spec.ts

@@ -0,0 +1,235 @@
+import { describe, expect, it, vi } from 'vitest';
+import { DouyinTranscriptJob, ViralAnalysis } from '../models/douyin-insight.model';
+import {
+  IpAccountSnapshot,
+  IpAccountWorkSnapshot,
+  IpAccountWorkTranscriptSnapshot,
+  IpMonitoredAccount,
+} from '../models/ip-operator.model';
+import { DouyinTranscriptService } from './douyin-transcript.service';
+import { IpAccountTranscriptOrchestratorService } from './ip-account-transcript-orchestrator.service';
+
+describe('IpAccountTranscriptOrchestratorService', () => {
+  it('returns existing transcript without starting a new transcript job', async () => {
+    const douyinTranscripts = transcriptServiceMock();
+    const service = new IpAccountTranscriptOrchestratorService(douyinTranscripts as unknown as DouyinTranscriptService);
+    const existingTranscript: IpAccountWorkTranscriptSnapshot = {
+      text: '视频已经有真实文字稿,不需要再次调用转写。',
+      source: 'asr',
+      confidence: 'high',
+      capturedAt: now(),
+    };
+
+    const result = await service.ensureTranscript({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      work: { ...workFixture(), transcript: existingTranscript },
+    });
+
+    expect(result.transcript).toEqual(existingTranscript);
+    expect(result.failureReason).toBeUndefined();
+    expect(douyinTranscripts.startForAnalysis).not.toHaveBeenCalled();
+    expect(result.diagnostics.some((item) => item.stage === 'transcript_result' && item.status === 'success')).toBe(true);
+  });
+
+  it('starts a transcript job with IP account work context when transcript is missing', async () => {
+    const completedJob = jobFixture({
+      status: 'completed',
+      text: '这是从视频媒体转写得到的完整视频文字稿。',
+    });
+    const douyinTranscripts = transcriptServiceMock({
+      startForAnalysis: vi.fn(async () => completedJob),
+    });
+    const service = new IpAccountTranscriptOrchestratorService(douyinTranscripts as unknown as DouyinTranscriptService);
+
+    const result = await service.ensureTranscript({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      work: workFixture(),
+      pollIntervalMs: 0,
+    });
+
+    expect(result.transcript).toMatchObject({
+      text: completedJob.text,
+      source: 'asr',
+      confidence: 'high',
+    });
+    expect(result.transcriptJob).toMatchObject({
+      id: completedJob.id,
+      awemeId: completedJob.awemeId,
+      status: 'completed',
+      text: completedJob.text,
+    });
+    expect(douyinTranscripts.refreshForAnalysis).not.toHaveBeenCalled();
+    const analysis = douyinTranscripts.startForAnalysis.mock.calls[0][0] as ViralAnalysis;
+    expect(analysis.awemeId).toBe('aweme-1');
+    expect(analysis.videoSnapshot.desc).toContain('工具安装顺序');
+    expect(analysis.videoSnapshot.authorName).toBe('FredTalk');
+    expect(analysis.commentsSnapshot.map((comment) => comment.text)).toEqual([
+      '刚开始不知道先用哪个工具',
+      '安装了很多但是不知道流程',
+    ]);
+  });
+
+  it('polls an in-progress transcript job until it returns completed text', async () => {
+    const pendingJob = jobFixture({ status: 'pending', text: undefined });
+    const completedJob = jobFixture({
+      status: 'completed',
+      text: '轮询后取得的真实视频文字稿。',
+      progress: 100,
+    });
+    const douyinTranscripts = transcriptServiceMock({
+      startForAnalysis: vi.fn(async () => pendingJob),
+      refreshForAnalysis: vi.fn(async () => completedJob),
+    });
+    const service = new IpAccountTranscriptOrchestratorService(douyinTranscripts as unknown as DouyinTranscriptService);
+
+    const result = await service.ensureTranscript({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      work: workFixture(),
+      maxPollAttempts: 2,
+      pollIntervalMs: 0,
+    });
+
+    expect(douyinTranscripts.refreshForAnalysis).toHaveBeenCalledTimes(1);
+    expect(result.transcript?.text).toBe(completedJob.text);
+    expect(result.diagnostics.some((item) => item.stage === 'transcript_poll' && item.status === 'success')).toBe(true);
+  });
+
+  it('returns a visible failure reason when transcript provider cannot produce text', async () => {
+    const failedJob = jobFixture({
+      status: 'needs_provider_config',
+      text: undefined,
+      warnings: ['缺少转写服务配置 VOC_TOKEN'],
+    });
+    const douyinTranscripts = transcriptServiceMock({
+      startForAnalysis: vi.fn(async () => failedJob),
+    });
+    const service = new IpAccountTranscriptOrchestratorService(douyinTranscripts as unknown as DouyinTranscriptService);
+
+    const result = await service.ensureTranscript({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      work: workFixture(),
+      pollIntervalMs: 0,
+    });
+
+    expect(result.transcript).toBeUndefined();
+    expect(result.failureReason).toContain('VOC_TOKEN');
+    expect(result.transcriptJob?.status).toBe('needs_provider_config');
+    expect(result.diagnostics.at(-1)).toMatchObject({
+      stage: 'transcript_result',
+      status: 'failed',
+    });
+  });
+
+  it('records start failures instead of falling through to work analysis', async () => {
+    const douyinTranscripts = transcriptServiceMock({
+      startForAnalysis: vi.fn(async () => {
+        throw new Error('转写网关不可用');
+      }),
+    });
+    const service = new IpAccountTranscriptOrchestratorService(douyinTranscripts as unknown as DouyinTranscriptService);
+
+    const result = await service.ensureTranscript({
+      account: accountFixture(),
+      snapshot: snapshotFixture(),
+      work: workFixture(),
+      pollIntervalMs: 0,
+    });
+
+    expect(result.transcript).toBeUndefined();
+    expect(result.failureReason).toBe('转写网关不可用');
+    expect(result.diagnostics.at(-1)).toMatchObject({
+      stage: 'transcript_request',
+      status: 'failed',
+    });
+  });
+});
+
+function transcriptServiceMock(overrides: Partial<{
+  startForAnalysis: ReturnType<typeof vi.fn>;
+  refreshForAnalysis: ReturnType<typeof vi.fn>;
+}> = {}) {
+  return {
+    startForAnalysis: vi.fn(async () => jobFixture({ status: 'completed', text: '默认视频文字稿' })),
+    refreshForAnalysis: vi.fn(async () => jobFixture({ status: 'completed', text: '默认刷新文字稿' })),
+    ...overrides,
+  };
+}
+
+function accountFixture(): IpMonitoredAccount {
+  return {
+    id: 'account-1',
+    userId: 'user-1',
+    platform: 'douyin',
+    role: 'owned',
+    displayName: 'FredTalk',
+    accountId: '917997605',
+    secUserId: 'sec-user-1',
+    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: 'Vibe Coding 实操',
+      followerCount: 1000,
+      followingCount: 10,
+      totalFavorited: 2000,
+      awemeCount: 10,
+    },
+    works: [workFixture()],
+  };
+}
+
+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 jobFixture(overrides: Partial<DouyinTranscriptJob> = {}): DouyinTranscriptJob {
+  return {
+    id: 'job-1',
+    awemeId: 'aweme-1',
+    provider: 'iflytek-gateway',
+    status: 'completed',
+    progress: 100,
+    text: '默认视频文字稿',
+    warnings: [],
+    createdAt: now(),
+    updatedAt: now(),
+    ...overrides,
+  };
+}
+
+function now(): string {
+  return '2026-07-01T00:00:00.000Z';
+}

+ 217 - 0
src/app/services/ip-account-transcript-orchestrator.service.ts

@@ -0,0 +1,217 @@
+import { Injectable } from '@angular/core';
+import { DouyinTranscriptJob, ViralAnalysis } from '../models/douyin-insight.model';
+import {
+  IpAccountSnapshot,
+  IpAccountStrategyDiagnosticEvent,
+  IpAccountWorkSnapshot,
+  IpAccountWorkTranscriptSnapshot,
+  IpMonitoredAccount,
+  IpTranscriptJobSnapshot,
+} from '../models/ip-operator.model';
+import { DouyinTranscriptService } from './douyin-transcript.service';
+
+const TRANSCRIPT_POLLING_STATUSES = new Set<string>([
+  'queued',
+  'resolving_detail',
+  'selecting_media',
+  'downloading_media',
+  'extracting_audio',
+  'submitting_provider',
+  'polling_provider',
+  'pending',
+]);
+
+export interface EnsureIpWorkTranscriptInput {
+  account: IpMonitoredAccount;
+  snapshot: IpAccountSnapshot;
+  work: IpAccountWorkSnapshot;
+  maxPollAttempts?: number;
+  pollIntervalMs?: number;
+}
+
+export interface EnsureIpWorkTranscriptResult {
+  transcript?: IpAccountWorkTranscriptSnapshot;
+  transcriptJob?: IpTranscriptJobSnapshot;
+  failureReason?: string;
+  diagnostics: IpAccountStrategyDiagnosticEvent[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountTranscriptOrchestratorService {
+  constructor(private readonly douyinTranscripts: DouyinTranscriptService) {}
+
+  async ensureTranscript(input: EnsureIpWorkTranscriptInput): Promise<EnsureIpWorkTranscriptResult> {
+    const diagnostics: IpAccountStrategyDiagnosticEvent[] = [];
+    const existingText = this.clean(input.work.transcript?.text);
+    if (existingText) {
+      this.pushDiagnostic(diagnostics, 'transcript_result', 'success', '作品已存在视频文字稿', `文字稿 ${existingText.length} 字符`);
+      return {
+        transcript: input.work.transcript,
+        diagnostics,
+      };
+    }
+
+    const analysis = this.toViralAnalysis(input);
+    this.pushDiagnostic(diagnostics, 'transcript_context', 'success', '已生成作品转写上下文', `awemeId ${analysis.awemeId} / 评论 ${analysis.commentsSnapshot.length} 条`);
+
+    let job: DouyinTranscriptJob;
+    try {
+      this.pushDiagnostic(diagnostics, 'transcript_request', 'started', '准备提交视频文字稿转写任务');
+      job = await this.douyinTranscripts.startForAnalysis(analysis);
+      analysis.transcriptJob = job;
+      this.pushDiagnostic(diagnostics, 'transcript_request', 'success', '视频文字稿转写任务已提交', this.jobDetail(job));
+    } catch (error) {
+      const reason = this.formatFailure(error);
+      this.pushDiagnostic(diagnostics, 'transcript_request', 'failed', '视频文字稿转写任务提交失败', reason);
+      return { failureReason: reason, diagnostics };
+    }
+
+    const maxPollAttempts = Math.max(0, input.maxPollAttempts ?? 30);
+    const pollIntervalMs = Math.max(0, input.pollIntervalMs ?? 2000);
+    for (let attempt = 0; attempt < maxPollAttempts && this.isPolling(job); attempt += 1) {
+      if (pollIntervalMs) await this.delay(pollIntervalMs);
+      try {
+        this.pushDiagnostic(diagnostics, 'transcript_poll', 'started', `查询视频文字稿任务进度 ${attempt + 1}/${maxPollAttempts}`, this.jobDetail(job));
+        job = await this.douyinTranscripts.refreshForAnalysis({ ...analysis, transcriptJob: job });
+        analysis.transcriptJob = job;
+        this.pushDiagnostic(diagnostics, 'transcript_poll', 'success', `视频文字稿任务状态:${job.status}`, this.jobDetail(job));
+      } catch (error) {
+        const reason = this.formatFailure(error);
+        this.pushDiagnostic(diagnostics, 'transcript_poll', 'failed', '视频文字稿任务查询失败', reason);
+        return { transcriptJob: this.toJobSnapshot(job), failureReason: reason, diagnostics };
+      }
+    }
+
+    const transcriptText = this.clean(job.text);
+    if (job.status === 'completed' && transcriptText) {
+      const transcript: IpAccountWorkTranscriptSnapshot = {
+        text: transcriptText,
+        source: 'asr',
+        confidence: 'high',
+        capturedAt: new Date().toISOString(),
+      };
+      this.pushDiagnostic(diagnostics, 'transcript_result', 'success', '视频文字稿已取得', `文字稿 ${transcriptText.length} 字符`);
+      return {
+        transcript,
+        transcriptJob: this.toJobSnapshot(job),
+        diagnostics,
+      };
+    }
+
+    const failureReason = job.errorMessage
+      || job.warnings?.[0]
+      || (this.isPolling(job) ? '视频文字稿仍在处理中,尚未完成' : `视频文字稿任务状态异常:${job.status}`);
+    this.pushDiagnostic(diagnostics, 'transcript_result', 'failed', '未取得可用于作品分析的视频文字稿', failureReason);
+    return {
+      transcriptJob: this.toJobSnapshot(job),
+      failureReason,
+      diagnostics,
+    };
+  }
+
+  private toViralAnalysis(input: EnsureIpWorkTranscriptInput): ViralAnalysis {
+    const now = new Date().toISOString();
+    const work = input.work;
+    return {
+      id: `ip_work_transcript_${work.id}`,
+      userId: input.account.userId || 'ip-operator',
+      awemeId: work.awemeId,
+      source: 'monitor',
+      videoSnapshot: {
+        awemeId: work.awemeId,
+        desc: work.desc || work.title || '',
+        authorName: input.snapshot.profile.nickname || input.account.displayName,
+        authorId: input.account.secUserId || input.account.accountId || '',
+        diggCount: work.metrics.likeCount,
+        commentCount: work.metrics.commentCount,
+        shareCount: work.metrics.shareCount,
+        playCount: work.metrics.playCount,
+        createTime: work.publishTime,
+        coverUrl: work.coverUrl,
+      },
+      commentsSnapshot: (work.comments || []).map((comment) => ({
+        id: comment.id,
+        text: comment.text,
+        likeCount: comment.likeCount,
+        replyCount: comment.replyCount,
+      })),
+      repliesSnapshot: [],
+      confidence: 'low',
+      analysis: {
+        summary: '',
+        hookType: '',
+        openingPattern: '',
+        contentRhythm: '',
+        conflict: '',
+        proofPoint: '',
+        audienceEmotion: '',
+        commentTrigger: '',
+        reusableFrame: '',
+        reusableAngles: [],
+        riskNotes: [],
+        evidenceRefs: [],
+      },
+      savedTopicIds: [],
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private toJobSnapshot(job: DouyinTranscriptJob | undefined): IpTranscriptJobSnapshot | undefined {
+    if (!job) return undefined;
+    return {
+      id: job.id,
+      awemeId: job.awemeId,
+      status: job.status,
+      stageLabel: job.stageLabel,
+      progress: job.progress,
+      text: job.text,
+      warnings: job.warnings,
+      errorMessage: job.errorMessage,
+      updatedAt: job.updatedAt,
+    };
+  }
+
+  private isPolling(job: DouyinTranscriptJob): boolean {
+    return TRANSCRIPT_POLLING_STATUSES.has(job.status);
+  }
+
+  private pushDiagnostic(
+    diagnostics: IpAccountStrategyDiagnosticEvent[],
+    stage: IpAccountStrategyDiagnosticEvent['stage'],
+    status: IpAccountStrategyDiagnosticEvent['status'],
+    message: string,
+    detail = '',
+  ): void {
+    diagnostics.push({
+      id: `transcript_diag_${stage}_${diagnostics.length + 1}_${Date.now()}`,
+      stage,
+      status,
+      message,
+      detail: this.clean(detail).slice(0, 260),
+      createdAt: new Date().toISOString(),
+    });
+  }
+
+  private jobDetail(job: DouyinTranscriptJob): string {
+    return [
+      `job ${job.id}`,
+      `status ${job.status}`,
+      job.stageLabel ? `stage ${job.stageLabel}` : '',
+      job.progress != null ? `progress ${job.progress}` : '',
+      job.orderId ? `order ${job.orderId}` : '',
+    ].filter(Boolean).join(' / ');
+  }
+
+  private clean(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+
+  private formatFailure(error: unknown): string {
+    return this.clean((error as any)?.message || error || '未知错误') || '未知错误';
+  }
+
+  private delay(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+}

+ 219 - 0
src/app/services/ip-account-work-analysis.service.spec.ts

@@ -0,0 +1,219 @@
+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';
+}

+ 300 - 0
src/app/services/ip-account-work-analysis.service.ts

@@ -0,0 +1,300 @@
+import { Injectable } from '@angular/core';
+import { firstValueFrom, timeout } from 'rxjs';
+import {
+  IpAccountSnapshot,
+  IpAccountStrategyDiagnosticEvent,
+  IpAccountWorkAnalysisRun,
+  IpAccountWorkEvidenceBundle,
+  IpAccountWorkSnapshot,
+  IpAccountWorkStrategyReport,
+  IpAccountWorkTranscriptSnapshot,
+  IpMonitoredAccount,
+} from '../models/ip-operator.model';
+import { IpAccountWorkReportService, ParsedIpAccountWorkReportInput } from './ip-account-work-report.service';
+import {
+  buildAccountWorkAnalysisPrompt,
+  IP_ACCOUNT_WORK_ANALYSIS_SYSTEM_PROMPT,
+} from './ip-operator-prompts';
+import { LlmService } from './llm.service';
+
+const IP_ACCOUNT_WORK_LLM_TIMEOUT_MS = 120000;
+const IP_ACCOUNT_WORK_LLM_MAX_TOKENS = 1800;
+
+export interface AnalyzeIpAccountWorkInput {
+  account: IpMonitoredAccount;
+  snapshot: IpAccountSnapshot;
+  work: IpAccountWorkSnapshot;
+  transcript: IpAccountWorkTranscriptSnapshot;
+  runId?: string;
+  createdAt?: string;
+}
+
+export interface AnalyzeIpAccountWorkResult {
+  run: IpAccountWorkAnalysisRun;
+  report?: IpAccountWorkStrategyReport;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountWorkAnalysisService {
+  constructor(
+    private readonly llm: LlmService,
+    private readonly workReports: IpAccountWorkReportService,
+  ) {}
+
+  buildPrompt(input: AnalyzeIpAccountWorkInput): string {
+    return buildAccountWorkAnalysisPrompt({
+      accountName: input.snapshot.profile.nickname || input.account.displayName,
+      accountIntentSummary: this.accountIntentSummary(input),
+      work: {
+        workId: input.work.id,
+        awemeId: input.work.awemeId,
+        title: input.work.title || input.work.desc || '未命名作品',
+        desc: input.work.desc || '',
+        metricsSummary: this.metricsSummary(input.work),
+        transcriptText: input.transcript.text,
+        comments: (input.work.comments || []).map((comment) => ({
+          id: comment.id,
+          text: comment.text,
+          likeCount: comment.likeCount,
+          replyCount: comment.replyCount,
+        })),
+      },
+    });
+  }
+
+  async analyzeWork(input: AnalyzeIpAccountWorkInput): Promise<AnalyzeIpAccountWorkResult> {
+    const diagnostics: IpAccountStrategyDiagnosticEvent[] = [];
+    const transcriptText = this.clean(input.transcript?.text);
+    const now = new Date().toISOString();
+    if (!transcriptText) {
+      const failureReason = '缺少视频文字稿,不能生成作品级 LLM 分析';
+      this.pushDiagnostic(diagnostics, 'context_built', 'failed', '作品分析上下文生成失败', failureReason);
+      this.pushDiagnostic(diagnostics, 'result_built', 'failed', '已生成作品分析失败记录', failureReason);
+      const run = this.buildRun(input, {
+        status: 'failed',
+        failureReason,
+        diagnostics,
+        now,
+      });
+      return { run };
+    }
+
+    const bundle = this.buildBundle(input);
+    this.pushDiagnostic(
+      diagnostics,
+      'context_built',
+      'success',
+      '作品分析上下文已生成',
+      `workId ${input.work.id} / 文字稿 ${transcriptText.length} 字符 / 评论 ${(input.work.comments || []).length} 条`,
+    );
+    const prompt = this.buildPrompt(input);
+    this.pushDiagnostic(
+      diagnostics,
+      'llm_request',
+      'started',
+      '准备调用 LLM 生成作品级分析',
+      `prompt ${prompt.length} 字符 / workId ${input.work.id} / 超时 ${IP_ACCOUNT_WORK_LLM_TIMEOUT_MS}ms`,
+    );
+
+    try {
+      const raw = await firstValueFrom(this.llm.askWithSystem(
+        IP_ACCOUNT_WORK_ANALYSIS_SYSTEM_PROMPT,
+        prompt,
+        { temperature: 0.2, max_tokens: IP_ACCOUNT_WORK_LLM_MAX_TOKENS },
+      ).pipe(timeout(IP_ACCOUNT_WORK_LLM_TIMEOUT_MS)));
+      this.pushDiagnostic(diagnostics, 'llm_response', 'success', 'LLM 已返回作品级原始响应', `响应 ${String(raw || '').length} 字符`);
+      const parsed = this.parseWorkResponse(raw, input.work.id);
+      this.pushDiagnostic(
+        diagnostics,
+        'llm_parse',
+        'success',
+        'LLM 响应已解析为作品报告结构',
+        `报告块 ${parsed.reports.length} 条`,
+      );
+      const report = this.toReport({
+        parsed,
+        bundle,
+        now,
+        sourceMode: 'llm',
+      });
+      this.pushDiagnostic(
+        diagnostics,
+        'result_built',
+        'success',
+        '已生成 LLM 作品级分析结果',
+        `reportId ${report.id} / block ${report.reports.length} 条`,
+      );
+      const run = this.buildRun(input, {
+        status: 'completed',
+        report,
+        diagnostics,
+        now,
+      });
+      return { run, report };
+    } catch (error) {
+      const failureReason = this.formatFailure(error);
+      this.pushDiagnostic(diagnostics, 'llm_fallback', 'fallback', '作品级 LLM 分析失败,已生成本地归档兜底', failureReason);
+      const report = this.workReports.buildLocalFallbackWorkReports({ now, bundles: [bundle] })[0];
+      this.pushDiagnostic(
+        diagnostics,
+        'result_built',
+        'fallback',
+        '已生成作品分析失败记录',
+        `failure ${failureReason} / fallbackReport ${report?.id || 'none'}`,
+      );
+      const run = this.buildRun(input, {
+        status: 'failed',
+        report,
+        failureReason,
+        diagnostics,
+        now,
+      });
+      return { run, report };
+    }
+  }
+
+  private parseWorkResponse(raw: string, workId: string): ParsedIpAccountWorkReportInput {
+    const parsed = JSON.parse(this.extractJson(raw)) as any;
+    const candidate = Array.isArray(parsed?.workReports)
+      ? parsed.workReports[0]
+      : Array.isArray(parsed?.reports) && parsed.workId
+        ? parsed
+        : parsed?.report;
+    if (!candidate || typeof candidate !== 'object') throw new Error('作品分析响应缺少报告对象');
+    const parsedWorkId = String(candidate.workId || '').trim();
+    if (parsedWorkId && parsedWorkId !== workId) throw new Error(`作品分析响应 workId 不匹配:${parsedWorkId}`);
+    const reports = Array.isArray(candidate.reports) ? candidate.reports : [];
+    if (!reports.length) throw new Error('作品分析响应缺少 reports');
+    return {
+      workId,
+      reports: reports.slice(0, 2),
+    };
+  }
+
+  private toReport(input: {
+    parsed: ParsedIpAccountWorkReportInput;
+    bundle: IpAccountWorkEvidenceBundle;
+    now: string;
+    sourceMode: 'llm' | 'local_fallback';
+  }): IpAccountWorkStrategyReport {
+    const report = this.workReports.normalizeParsedWorkReports({
+      now: input.now,
+      sourceMode: input.sourceMode,
+      parsedReports: [input.parsed],
+      bundles: [input.bundle],
+    })[0];
+    if (!report) throw new Error('作品分析响应无法归一化为报告');
+    return report;
+  }
+
+  private buildBundle(input: AnalyzeIpAccountWorkInput): IpAccountWorkEvidenceBundle {
+    const work = input.work;
+    const transcriptText = this.compact(input.transcript.text, 2600);
+    return {
+      workId: work.id,
+      workEvidenceRefId: `work_evidence_${work.id}`,
+      title: this.compact(work.title || work.desc || '未命名作品', 80),
+      description: this.compact(work.desc || work.title || '', 220),
+      transcriptText,
+      transcriptSource: input.transcript.source,
+      transcriptConfidence: input.transcript.confidence,
+      transcriptSummary: this.compact(transcriptText, 320),
+      metricsSummary: this.metricsSummary(work),
+      structureSummary: [
+        work.structure?.hook ? `开头:${work.structure.hook}` : '',
+        work.structure?.topic ? `主题:${work.structure.topic}` : '',
+        work.structure?.style ? `风格:${work.structure.style}` : '',
+        work.structure?.cta ? `转化:${work.structure.cta}` : '',
+      ].filter(Boolean).join(';') || '未识别结构',
+      commentEvidenceRefs: (work.comments || []).slice(0, 12).map((comment) => ({
+        id: comment.id,
+        sourceId: comment.id,
+        text: this.compact(comment.text, 120),
+        likeCount: comment.likeCount,
+      })),
+    };
+  }
+
+  private buildRun(
+    input: AnalyzeIpAccountWorkInput,
+    options: {
+      status: 'completed' | 'failed';
+      report?: IpAccountWorkStrategyReport;
+      failureReason?: string;
+      diagnostics: IpAccountStrategyDiagnosticEvent[];
+      now: string;
+    },
+  ): IpAccountWorkAnalysisRun {
+    return {
+      accountId: input.account.id,
+      snapshotId: input.snapshot.id,
+      workId: input.work.id,
+      awemeId: input.work.awemeId,
+      status: options.status,
+      transcriptSource: input.transcript?.source,
+      report: options.report,
+      failureReason: options.failureReason,
+      diagnostics: options.diagnostics,
+      id: input.runId || `work_analysis_run_${input.work.id}_${Date.now()}`,
+      createdAt: input.createdAt || options.now,
+      updatedAt: options.now,
+    };
+  }
+
+  private accountIntentSummary(input: AnalyzeIpAccountWorkInput): string {
+    return [
+      input.snapshot.profile.signature ? `主页简介:${input.snapshot.profile.signature}` : '',
+      input.account.role === 'owned' ? '账号角色:自有账号' : '账号角色:对标账号',
+      `近期作品数:${input.snapshot.works.length}`,
+    ].filter(Boolean).join(';');
+  }
+
+  private metricsSummary(work: IpAccountWorkSnapshot): string {
+    return [
+      `播放 ${work.metrics.playCount || 0}`,
+      `点赞 ${work.metrics.likeCount || 0}`,
+      `评论 ${work.metrics.commentCount || 0}`,
+      `收藏 ${work.metrics.collectCount || 0}`,
+      `分享 ${work.metrics.shareCount || 0}`,
+    ].join(' / ');
+  }
+
+  private pushDiagnostic(
+    diagnostics: IpAccountStrategyDiagnosticEvent[],
+    stage: IpAccountStrategyDiagnosticEvent['stage'],
+    status: IpAccountStrategyDiagnosticEvent['status'],
+    message: string,
+    detail = '',
+  ): void {
+    diagnostics.push({
+      id: `work_analysis_diag_${stage}_${diagnostics.length + 1}_${Date.now()}`,
+      stage,
+      status,
+      message,
+      detail: this.compact(detail, 260),
+      createdAt: new Date().toISOString(),
+    });
+  }
+
+  private extractJson(raw: string): string {
+    const text = String(raw || '').trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim();
+    if (text.startsWith('{') && text.endsWith('}')) return text;
+    const start = text.indexOf('{');
+    const end = text.lastIndexOf('}');
+    if (start < 0 || end <= start) throw new Error('作品分析响应中没有可解析 JSON');
+    return text.slice(start, end + 1);
+  }
+
+  private formatFailure(error: unknown): string {
+    return this.compact(String((error as any)?.message || error || '未知错误').replace(/\s+/g, ' ').trim(), 180) || '未知错误';
+  }
+
+  private clean(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+
+  private compact(value: unknown, max: number): string {
+    const normalized = this.clean(value);
+    return normalized.length > max ? `${normalized.slice(0, Math.max(0, max - 1))}…` : normalized;
+  }
+}

+ 239 - 0
src/app/services/ip-account-work-media-analysis.service.spec.ts

@@ -0,0 +1,239 @@
+import { of, throwError } from 'rxjs';
+import { describe, expect, it, vi } from 'vitest';
+import { IpAccountWorkMediaAnalysisService } from './ip-account-work-media-analysis.service';
+
+describe('IpAccountWorkMediaAnalysisService', () => {
+  it('analyzes an existing transcript with the digital-human text prompt', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({ content: '主题:AI 工具选择', finishReason: 'stop' })),
+    };
+    const service = createService(llm);
+
+    const result = await service.analyze({ transcript: '这是一条介绍 AI 工具选择顺序的视频文字稿。' });
+
+    expect(result.source).toBe('transcript_text');
+    expect(result.analysisText).toBe('主题:AI 工具选择');
+    expect(result.transcript).toBe('这是一条介绍 AI 工具选择顺序的视频文字稿。');
+    expect(llm.askWithSystemDetailed).toHaveBeenCalledWith(
+      expect.stringContaining('专业的短视频内容分析师'),
+      expect.stringContaining('这是一条介绍 AI 工具选择顺序的视频文字稿。'),
+      { model: 'gemini-2.5-flash', max_tokens: 4096 },
+    );
+  });
+
+  it('adds comment samples as user feedback evidence when analyzing a transcript', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({ content: '【用户反馈信号】\n用户在问落地步骤', finishReason: 'stop' })),
+    };
+    const service = createService(llm);
+
+    await service.analyze({
+      transcript: '视频讲解如何选择 AI 工具。',
+      videoSnapshot: {
+        desc: 'AI 工具选择教程',
+        diggCount: 120,
+        commentCount: 9,
+      },
+      commentSamples: [
+        { id: 'c-low', text: '学到了', likeCount: 1 },
+        { id: 'c-high', text: '我最想知道这些工具到底先用哪个,能不能给一个顺序', likeCount: 18, replyCount: 2 },
+      ],
+    });
+
+    const userInput = llm.askWithSystemDetailed.mock.calls[0][1] as string;
+    expect(llm.askWithSystemDetailed.mock.calls[0][0]).toContain('评论样本是用户反馈证据');
+    expect(userInput).toContain('【视频文字稿】');
+    expect(userInput).toContain('视频讲解如何选择 AI 工具。');
+    expect(userInput).toContain('【作品指标与文案】');
+    expect(userInput).toContain('点赞:120');
+    expect(userInput).toContain('【评论样本】');
+    expect(userInput).toContain('我最想知道这些工具到底先用哪个');
+  });
+
+  it('keeps the transcript when text analysis is unavailable', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(throwError(() => new Error('LLM unavailable'))),
+    };
+    const service = createService(llm);
+
+    const result = await service.analyze({ transcript: '可用的视频文字稿。' });
+
+    expect(result.analysisText).toContain('AI 文本分析暂不可用');
+    expect(result.transcript).toBe('可用的视频文字稿。');
+    expect(result.warnings).toContain('AI 文本分析暂不可用');
+  });
+
+  it('parses Gemini video/audio result into analysis and transcript', () => {
+    const service = createService({});
+
+    const result = service.parseGeminiResult('【内容分析】\n主题:效率工具\n---TRANSCRIPT_START---\n完整口播\n---TRANSCRIPT_END---');
+
+    expect(result.analysisText).toBe('主题:效率工具');
+    expect(result.transcript).toBe('完整口播');
+  });
+
+  it('extracts transcript from a Douyin work before text analysis', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({ content: '主题:逐字稿分析', finishReason: 'stop' })),
+    };
+    const insight = {
+      startTranscript: vi.fn().mockResolvedValue({
+          id: 'job-1',
+          awemeId: 'aweme-1',
+          provider: 'whisper',
+          status: 'completed',
+          text: '这是从抖音作品提取出来的视频文字稿。',
+          warnings: [],
+          createdAt: now(),
+          updatedAt: now(),
+      }),
+      getTranscript: vi.fn(),
+    };
+    const service = createService(llm, {}, {}, insight);
+
+    const result = await service.analyze({
+      awemeId: 'aweme-1',
+      analysisId: 'ip-work-analysis-1',
+      videoSnapshot: { awemeId: 'aweme-1', desc: '作品文案' },
+      transcriptPolling: { intervalMs: 500, maxAttempts: 1 },
+    });
+
+    expect(insight.startTranscript).toHaveBeenCalledWith('aweme-1', 'ip-work-analysis-1');
+    expect(result.source).toBe('douyin_transcript');
+    expect(result.transcript).toBe('这是从抖音作品提取出来的视频文字稿。');
+    expect(result.analysisText).toBe('主题:逐字稿分析');
+    expect(llm.askWithSystemDetailed).toHaveBeenCalledWith(
+      expect.stringContaining('专业的短视频内容分析师'),
+      expect.stringContaining('这是从抖音作品提取出来的视频文字稿。'),
+      { model: 'gemini-2.5-flash', max_tokens: 4096 },
+    );
+  });
+
+  it('keeps polling when a transcript status query has a transient network failure', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({ content: '轮询后完成的分析', finishReason: 'stop' })),
+    };
+    const insight = {
+      startTranscript: vi.fn().mockResolvedValue({
+        id: 'job-1',
+        awemeId: 'aweme-1',
+        provider: 'iflytek-gateway',
+        status: 'pending',
+        warnings: [],
+        createdAt: now(),
+        updatedAt: now(),
+      }),
+      getTranscript: vi.fn()
+        .mockRejectedValueOnce(new Error('fetch failed'))
+        .mockResolvedValueOnce({
+          id: 'job-1',
+          awemeId: 'aweme-1',
+          provider: 'iflytek-gateway',
+          status: 'completed',
+          text: '网络抖动后拿到的逐字稿',
+          warnings: [],
+          createdAt: now(),
+          updatedAt: now(),
+        }),
+    };
+    const service = createService(llm, {}, {}, insight);
+
+    const result = await service.analyze({
+      awemeId: 'aweme-1',
+      analysisId: 'ip-work-analysis-1',
+      transcriptPolling: { intervalMs: 0, maxAttempts: 2 },
+    });
+
+    expect(insight.getTranscript).toHaveBeenCalledTimes(2);
+    expect(result.source).toBe('douyin_transcript');
+    expect(result.transcript).toBe('网络抖动后拿到的逐字稿');
+    expect(result.warnings).toContain('逐字稿查询曾出现网络波动,系统已自动重试并完成。');
+    expect(result.warnings.some((item) => item.includes('fetch failed'))).toBe(false);
+  });
+
+  it('does not expose internal transcript job details to the UI result', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({ content: '完整分析', finishReason: 'stop' })),
+    };
+    const insight = {
+      startTranscript: vi.fn().mockResolvedValue({
+        id: 'job-1',
+        awemeId: 'aweme-1',
+        provider: 'iflytek-gateway',
+        status: 'completed',
+        text: '可用于分析的逐字稿',
+        warnings: [
+          '已提交转写任务:DKHJQ20260703112653609zkUXCBXzt9jWY7P9',
+          '媒体来源:audio music.play_url.url_list',
+          '转写任务仍在处理中。',
+        ],
+        createdAt: now(),
+        updatedAt: now(),
+      }),
+      getTranscript: vi.fn(),
+    };
+    const service = createService(llm, {}, {}, insight);
+
+    const result = await service.analyze({ awemeId: 'aweme-1', transcriptPolling: { intervalMs: 0, maxAttempts: 1 } });
+
+    expect(result.analysisText).toBe('完整分析');
+    expect(result.warnings).toEqual([]);
+  });
+
+  it('hides incomplete analysis when the LLM response is cut by token length', async () => {
+    const llm = {
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({
+        content: '只有半截的分析内容',
+        finishReason: 'length',
+      })),
+    };
+    const service = createService(llm);
+
+    const result = await service.analyze({ transcript: '一段足够触发分析的视频文字稿。' });
+
+    expect(result.analysisText).toContain('AI 分析结果被模型截断');
+    expect(result.analysisText).not.toContain('只有半截的分析内容');
+    expect(result.warnings).toContain('AI 分析被截断,本次未展示不完整结果。');
+  });
+
+  it('rejects empty input instead of guessing', async () => {
+    const service = createService({});
+
+    await expect(service.analyze({})).rejects.toThrow('缺少可分析的视频源或视频文字稿');
+  });
+});
+
+function createService(
+  llmPatch: Record<string, unknown>,
+  browserFfmpegPatch: Record<string, unknown> = {},
+  httpPatch: Record<string, unknown> = {},
+  insightPatch: Record<string, unknown> = {},
+): IpAccountWorkMediaAnalysisService {
+  const llm = {
+    askWithSystem: vi.fn(),
+    askWithSystemDetailed: vi.fn(),
+    analyzeVideo: vi.fn(),
+    analyzeAudio: vi.fn(),
+    fileToBase64: vi.fn(),
+    ...llmPatch,
+  };
+  const browserFfmpeg = {
+    extractAudioForGemini: vi.fn(),
+    ...browserFfmpegPatch,
+  };
+  const http = {
+    post: vi.fn(),
+    get: vi.fn(),
+    ...httpPatch,
+  };
+  const insight = {
+    startTranscript: vi.fn(),
+    getTranscript: vi.fn(),
+    ...insightPatch,
+  };
+  return new IpAccountWorkMediaAnalysisService(llm as any, browserFfmpeg as any, http as any, insight as any);
+}
+
+function now(): string {
+  return '2026-07-02T00:00:00.000Z';
+}

+ 463 - 0
src/app/services/ip-account-work-media-analysis.service.ts

@@ -0,0 +1,463 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { DouyinTranscriptJob, ViralVideoSnapshot } from '../models/douyin-insight.model';
+import { BrowserFfmpegService } from './browser-ffmpeg.service';
+import { DouyinInsightService } from './douyin-insight.service';
+import { LlmService } from './llm.service';
+
+export type IpAccountWorkMediaAnalysisSource =
+  | 'gemini_video'
+  | 'gemini_audio'
+  | 'whisper_text'
+  | 'douyin_transcript'
+  | 'transcript_text';
+
+export interface IpAccountWorkMediaAnalysisInput {
+  awemeId?: string;
+  analysisId?: string;
+  transcript?: string;
+  file?: File;
+  videoUrl?: string;
+  videoId?: string;
+  videoSnapshot?: Partial<ViralVideoSnapshot>;
+  commentSamples?: IpAccountWorkCommentSample[];
+  transcriptPolling?: {
+    intervalMs?: number;
+    maxAttempts?: number;
+  };
+}
+
+export interface IpAccountWorkCommentSample {
+  id?: string;
+  text: string;
+  likeCount?: number;
+  replyCount?: number;
+  authorName?: string;
+}
+
+export interface IpAccountWorkMediaAnalysisResult {
+  transcript: string;
+  analysisText: string;
+  source: IpAccountWorkMediaAnalysisSource;
+  warnings: string[];
+}
+
+const TRANSCRIPT_ANALYSIS_MAX_TOKENS = 4096;
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountWorkMediaAnalysisService {
+  private readonly transcriptPollingStatuses = new Set<DouyinTranscriptJob['status']>([
+    'queued',
+    'resolving_detail',
+    'selecting_media',
+    'downloading_media',
+    'extracting_audio',
+    'submitting_provider',
+    'polling_provider',
+    'pending',
+  ]);
+
+  constructor(
+    private readonly llmService: LlmService,
+    private readonly browserFfmpeg: BrowserFfmpegService,
+    private readonly http: HttpClient,
+    private readonly douyinInsight: DouyinInsightService,
+  ) {}
+
+  async analyze(input: IpAccountWorkMediaAnalysisInput): Promise<IpAccountWorkMediaAnalysisResult> {
+    const transcript = this.cleanText(input.transcript);
+    const sizeMB = input.file ? input.file.size / 1024 / 1024 : 0;
+
+    if (input.file && sizeMB <= 20) {
+      try {
+        return await this.analyzeGeminiVideo(input.file);
+      } catch (err) {
+        if (input.videoId) return this.analyzeWhisperFallback(input.videoId, transcript, this.errorText(err), input);
+        if (transcript) return this.analyzeTranscript(transcript, [`Gemini 视频理解失败:${this.errorText(err)}`], 'transcript_text', input);
+        throw err;
+      }
+    }
+
+    if (input.file || input.videoUrl) {
+      try {
+        return await this.analyzeGeminiAudio(input.file || input.videoUrl!);
+      } catch (err) {
+        if (input.videoId) return this.analyzeWhisperFallback(input.videoId, transcript, this.errorText(err), input);
+        if (transcript) return this.analyzeTranscript(transcript, [`Gemini 音频识别失败:${this.errorText(err)}`], 'transcript_text', input);
+        throw err;
+      }
+    }
+
+    if (input.videoId) {
+      return this.analyzeWhisperFallback(input.videoId, transcript, '', input);
+    }
+
+    if (input.awemeId) {
+      return this.analyzeDouyinTranscript(input, transcript);
+    }
+
+    if (transcript) {
+      return this.analyzeTranscript(transcript, [], 'transcript_text', input);
+    }
+
+    throw new Error('缺少可分析的视频源或视频文字稿');
+  }
+
+  parseGeminiResult(raw: string): { transcript: string; analysisText: string } {
+    const text = String(raw || '').trim();
+    const match = text.match(/---TRANSCRIPT_START---([\s\S]*?)---TRANSCRIPT_END---/);
+    if (!match) {
+      return { transcript: '', analysisText: text || '分析完成' };
+    }
+
+    const analysisPart = text.substring(0, text.indexOf('---TRANSCRIPT_START---')).trim();
+    return {
+      transcript: match[1].trim(),
+      analysisText: analysisPart.replace(/^【内容分析】\s*/, '') || '分析完成',
+    };
+  }
+
+  private async analyzeGeminiVideo(file: File): Promise<IpAccountWorkMediaAnalysisResult> {
+    const base64 = await this.llmService.fileToBase64(file);
+    const raw = await firstValueFrom(this.llmService.analyzeVideo(base64, file.type || 'video/mp4', this.buildAnalysisPrompt('video'), {
+      model: 'gemini-2.5-flash',
+      generationConfig: { maxOutputTokens: 8192 },
+    }));
+    const parsed = this.parseGeminiResult(raw);
+    return {
+      transcript: parsed.transcript,
+      analysisText: parsed.analysisText,
+      source: 'gemini_video',
+      warnings: parsed.transcript ? [] : ['Gemini 未返回可拆分的视频文字稿'],
+    };
+  }
+
+  private async analyzeGeminiAudio(source: File | string): Promise<IpAccountWorkMediaAnalysisResult> {
+    const audio = await this.browserFfmpeg.extractAudioForGemini(source, `ip-work-media-${Date.now()}.m4a`);
+    if ((audio.sizeMB || 0) > 20) {
+      throw new Error('音频文件超过 Gemini 音频识别限制');
+    }
+
+    const raw = await firstValueFrom(this.llmService.analyzeAudio(audio.base64, audio.mimeType || 'audio/mp4', this.buildAnalysisPrompt('audio'), {
+      model: 'gemini-2.5-flash',
+      generationConfig: { maxOutputTokens: 8192 },
+    }));
+    const parsed = this.parseGeminiResult(raw);
+    return {
+      transcript: parsed.transcript,
+      analysisText: parsed.analysisText,
+      source: 'gemini_audio',
+      warnings: parsed.transcript ? [] : ['Gemini 未返回可拆分的音频文字稿'],
+    };
+  }
+
+  private async analyzeWhisperFallback(
+    videoId: string,
+    fallbackTranscript = '',
+    previousError = '',
+    context: IpAccountWorkMediaAnalysisInput = {},
+  ): Promise<IpAccountWorkMediaAnalysisResult> {
+    const res = await firstValueFrom(this.http.post<any>('/backend/api/whisper/transcribe', {
+      videoId,
+      language: 'Chinese',
+      model: 'large',
+    }));
+    const transcript = this.cleanText(res?.transcript) || fallbackTranscript;
+    if (!res?.success || !transcript) {
+      throw new Error(res?.error || 'Whisper 识别完成但未返回文字稿');
+    }
+
+    return this.analyzeTranscript(
+      transcript,
+      previousError ? [`Gemini 识别失败后已降级 Whisper:${previousError}`] : [],
+      'whisper_text',
+      context,
+    );
+  }
+
+  private async analyzeDouyinTranscript(
+    input: IpAccountWorkMediaAnalysisInput,
+    fallbackTranscript: string,
+  ): Promise<IpAccountWorkMediaAnalysisResult> {
+    let job = await this.douyinInsight.startTranscript(input.awemeId!, input.analysisId || `ip-work-${input.awemeId}`);
+    if (!job) {
+      if (fallbackTranscript) {
+        return this.analyzeTranscript(fallbackTranscript, ['逐字稿任务未返回,已使用已有文字稿'], 'transcript_text', input);
+      }
+      throw new Error('逐字稿任务未返回,无法分析视频');
+    }
+
+    job = await this.waitForDouyinTranscript(job, input.transcriptPolling);
+    const transcript = this.cleanText(job.text) || fallbackTranscript;
+    if (job.status !== 'completed' || !transcript) {
+      throw new Error(this.jobErrorMessage(job) || this.transcriptStatusMessage(job.status));
+    }
+
+    return this.analyzeTranscript(transcript, job.warnings || [], 'douyin_transcript', input);
+  }
+
+  private async waitForDouyinTranscript(
+    initialJob: DouyinTranscriptJob,
+    options: IpAccountWorkMediaAnalysisInput['transcriptPolling'] = {},
+  ): Promise<DouyinTranscriptJob> {
+    let job = initialJob;
+    const intervalMs = Math.max(500, options?.intervalMs ?? 2000);
+    const maxAttempts = Math.max(1, options?.maxAttempts ?? 90);
+    const transientWarnings: string[] = [];
+
+    for (let attempt = 0; attempt < maxAttempts && this.transcriptPollingStatuses.has(job.status); attempt += 1) {
+      await this.delay(intervalMs);
+      try {
+        job = await this.douyinInsight.getTranscript(job.id) || job;
+      } catch (err) {
+        const message = this.errorText(err);
+        if (this.isTransientPollingError(message) && attempt < maxAttempts - 1) {
+          transientWarnings.push(`逐字稿轮询临时失败,已继续重试:${message}`);
+          continue;
+        }
+        throw err;
+      }
+    }
+
+    if (transientWarnings.length) {
+      job = {
+        ...job,
+        warnings: [...(job.warnings || []), ...transientWarnings],
+      };
+    }
+    return job;
+  }
+
+  private async analyzeTranscript(
+    transcript: string,
+    warnings: string[] = [],
+    source: IpAccountWorkMediaAnalysisSource = 'transcript_text',
+    context: IpAccountWorkMediaAnalysisInput = {},
+  ): Promise<IpAccountWorkMediaAnalysisResult> {
+    const cleanTranscript = this.cleanText(transcript);
+    if (!cleanTranscript) throw new Error('缺少视频文字稿,无法进行内容分析');
+    const publicWarnings = this.publicTranscriptWarnings(warnings);
+    const analysisInput = this.buildTranscriptAnalysisInput(cleanTranscript, context);
+
+    try {
+      const analysis = await firstValueFrom(this.llmService.askWithSystemDetailed(
+        this.buildTranscriptWithCommentsPrompt(),
+        analysisInput,
+        { model: 'gemini-2.5-flash', max_tokens: TRANSCRIPT_ANALYSIS_MAX_TOKENS },
+      ));
+      if (analysis.finishReason === 'length') {
+        return {
+          transcript: cleanTranscript,
+          analysisText: 'AI 分析结果被模型截断,系统已隐藏不完整内容。请重新点击“分析视频”,或缩短文字稿后再试。',
+          source,
+          warnings: [...publicWarnings, 'AI 分析被截断,本次未展示不完整结果。'],
+        };
+      }
+      return {
+        transcript: cleanTranscript,
+        analysisText: analysis.content || '分析完成',
+        source,
+        warnings: publicWarnings,
+      };
+    } catch {
+      return {
+        transcript: cleanTranscript,
+        analysisText: '(AI 文本分析暂不可用,已完成文字稿读取)',
+        source,
+        warnings: [...publicWarnings, 'AI 文本分析暂不可用'],
+      };
+    }
+  }
+
+  private buildAnalysisPrompt(kind: 'video' | 'audio'): string {
+    const taskOne = kind === 'video'
+      ? '从画面和音频两个维度分析:1.主题/核心观点 2.画面描述 3.目标受众 4.内容风格 5.亮点(3-5条) 6.视觉风格 7.改进建议'
+      : '从音频分析:1.主题/核心观点 2.目标受众 3.内容风格 4.亮点(3-5条) 5.改进建议';
+
+    return `你是一个专业的内容分析师。请完成两个任务:
+
+===== 任务一:内容分析 =====
+${taskOne}
+
+===== 任务二:完整语音转录 =====
+请将所有语音内容逐字转录为中文,忠实还原、不遗漏、不改写、不总结,按自然段落分段。
+
+请严格按以下格式输出(用分隔线区分两部分):
+
+【内容分析】
+(任务一结果)
+
+---TRANSCRIPT_START---
+(任务二完整转录)
+---TRANSCRIPT_END---`;
+  }
+
+  private buildTranscriptWithCommentsPrompt(): string {
+    return `你是一个专业的短视频内容分析师。请同时使用视频文字稿、作品指标和评论样本进行分析。
+
+重要边界:
+- 视频文字稿是内容主线,用来判断主题、结构、观点和表达。
+- 评论样本是用户反馈证据,用来归纳用户关注理由、痛点、误解、负反馈和行动意图。
+- 不要把评论原文直接复制成方向或结论,不要寒暄、不要复述整段原文、不要编造数据。
+- 如果评论样本不足,要明确说明“评论信号不足”,但仍基于文字稿完成内容分析。
+
+请按以下固定结构输出,总字数控制在 1100 字以内:
+
+【主题定位】
+- 用 2-3 句话说明视频讲什么、解决什么问题、核心观点是什么。
+
+【内容结构】
+- 拆出开头钩子、主体推进、结尾引导各自的作用。
+
+【目标受众】
+- 判断这条内容吸引哪类用户、他们为什么会停留。
+
+【用户反馈信号】
+- 结合评论样本归纳用户真实关注点、疑问、痛点、误解、负反馈或行动意图;不要直接复制评论原文。
+
+【可复用亮点】
+- 列 3-5 条可借鉴的选题、表达、案例或节奏设计。
+
+【改进建议】
+- 列 2-3 条面向账号运营的改进建议。`;
+  }
+
+  private buildTranscriptAnalysisInput(transcript: string, context: IpAccountWorkMediaAnalysisInput): string {
+    return [
+      '【视频文字稿】',
+      transcript,
+      '',
+      '【作品指标与文案】',
+      this.videoMetricsText(context.videoSnapshot),
+      '',
+      '【评论样本】',
+      this.commentSamplesText(context.commentSamples || []),
+    ].join('\n');
+  }
+
+  private videoMetricsText(snapshot?: Partial<ViralVideoSnapshot>): string {
+    if (!snapshot) return '未提供作品指标。';
+    const lines = [
+      snapshot.desc ? `作品文案:${this.truncateText(snapshot.desc, 180)}` : '',
+      snapshot.playCount !== undefined ? `播放:${snapshot.playCount}` : '',
+      snapshot.diggCount !== undefined ? `点赞:${snapshot.diggCount}` : '',
+      snapshot.commentCount !== undefined ? `评论:${snapshot.commentCount}` : '',
+      snapshot.shareCount !== undefined ? `分享:${snapshot.shareCount}` : '',
+      snapshot.createTime ? `发布时间:${snapshot.createTime}` : '',
+    ].filter(Boolean);
+    return lines.length ? lines.join('\n') : '未提供作品指标。';
+  }
+
+  private commentSamplesText(samples: IpAccountWorkCommentSample[]): string {
+    const selected = (samples || [])
+      .map((item) => ({
+        ...item,
+        text: this.cleanText(item.text),
+      }))
+      .filter((item) => item.text.length >= 2)
+      .sort((a, b) => this.commentSignalScore(b) - this.commentSignalScore(a))
+      .slice(0, 16);
+    if (!selected.length) return '暂无可用评论样本。';
+    return selected.map((item, index) => {
+      const stats = [
+        item.likeCount !== undefined ? `赞${item.likeCount}` : '',
+        item.replyCount !== undefined ? `回复${item.replyCount}` : '',
+      ].filter(Boolean).join(' / ');
+      const prefix = stats ? `(${stats})` : '';
+      return `${index + 1}. ${prefix}${this.truncateText(item.text, 140)}`;
+    }).join('\n');
+  }
+
+  private commentSignalScore(item: IpAccountWorkCommentSample): number {
+    const likeScore = Number(item.likeCount || 0);
+    const replyScore = Number(item.replyCount || 0) * 2;
+    const lengthScore = Math.min(this.cleanText(item.text).length, 80) / 20;
+    return likeScore + replyScore + lengthScore;
+  }
+
+  private truncateText(value: unknown, maxLength: number): string {
+    const text = this.cleanText(value);
+    return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+  }
+
+  private publicTranscriptWarnings(warnings: string[]): string[] {
+    const mapped = (warnings || [])
+      .map((item) => this.readableMessage(item))
+      .map((item) => this.toPublicTranscriptWarning(item))
+      .filter(Boolean) as string[];
+    return [...new Set(mapped)];
+  }
+
+  private toPublicTranscriptWarning(message: string): string {
+    const text = String(message || '').trim();
+    if (!text) return '';
+    if (/^已提交转写任务[::]/.test(text)) return '';
+    if (/^媒体来源[::]/.test(text)) return '';
+    if (/^转写任务仍在处理中[。.]?$/.test(text)) return '';
+    if (/orderId|mediaKeyPath|sourceKind|audio music|play_url|url_list|transcript_\d+/i.test(text)) return '';
+    if (/逐字稿轮询临时失败/.test(text)) return '逐字稿查询曾出现网络波动,系统已自动重试并完成。';
+    return text;
+  }
+
+  private cleanText(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+
+  private transcriptStatusMessage(status: DouyinTranscriptJob['status']): string {
+    const labels: Record<DouyinTranscriptJob['status'], string> = {
+      queued: '逐字稿任务排队中',
+      resolving_detail: '正在解析视频详情',
+      selecting_media: '正在选择可转写媒体源',
+      downloading_media: '正在下载媒体',
+      extracting_audio: '正在提取音轨',
+      submitting_provider: '正在提交转写服务',
+      polling_provider: '正在等待转写服务返回',
+      pending: '逐字稿任务处理中',
+      completed: '逐字稿已完成',
+      failed: '逐字稿提取失败',
+      needs_media: '缺少可转写的视频媒体',
+      needs_media_processing: '视频媒体需要处理后才能转写',
+      needs_provider_config: '逐字稿服务配置缺失',
+    };
+    return labels[status] || '逐字稿提取失败';
+  }
+
+  private jobErrorMessage(job: DouyinTranscriptJob): string {
+    const errorText = this.readableMessage(job.errorMessage);
+    if (errorText) return errorText;
+    const warning = (job.warnings || []).map((item) => this.readableMessage(item)).find(Boolean);
+    return warning || '';
+  }
+
+  private readableMessage(value: unknown): string {
+    if (!value) return '';
+    if (typeof value === 'string') return value === '[object Object]' ? '' : value;
+    const obj = value as any;
+    const parts = [
+      obj.message,
+      obj.error,
+      obj.code ? `code=${obj.code}` : '',
+      obj.type ? `type=${obj.type}` : '',
+    ].filter(Boolean);
+    if (parts.length) return parts.join(';');
+    try {
+      return JSON.stringify(value);
+    } catch {
+      return '';
+    }
+  }
+
+  private isTransientPollingError(message: string): boolean {
+    return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/i.test(message || '');
+  }
+
+  private delay(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
+  private errorText(err: unknown): string {
+    const value = err as any;
+    return value?.error?.error || value?.message || String(err || '未知错误');
+  }
+}

+ 156 - 0
src/app/services/ip-account-work-report.service.spec.ts

@@ -0,0 +1,156 @@
+import { describe, expect, it } from 'vitest';
+import {
+  IpAccountSnapshot,
+  IpAccountStrategyEvidenceRef,
+} from '../models/ip-operator.model';
+import { IpAccountWorkReportService } from './ip-account-work-report.service';
+
+describe('IpAccountWorkReportService', () => {
+  const service = new IpAccountWorkReportService();
+
+  it('builds work bundles with comments as supporting evidence only', () => {
+    const bundles = service.buildWorkEvidenceBundles(snapshotFixture(), rawRefsFixture());
+
+    expect(bundles).toHaveLength(1);
+    expect(bundles[0].workId).toBe('work-1');
+    expect(bundles[0].workEvidenceRefId).toBe('ev_work_1');
+    expect(bundles[0].transcriptText).toContain('新手先判断任务');
+    expect(bundles[0].commentEvidenceRefs.map((item) => item.id)).toEqual(['ev_comment_1', 'ev_comment_2']);
+  });
+
+  it('does not build work bundles when a work has no transcript', () => {
+    const snapshot = snapshotFixture();
+    delete snapshot.works[0].transcript;
+
+    const bundles = service.buildWorkEvidenceBundles(snapshot, rawRefsFixture());
+    const reports = service.buildLocalFallbackWorkReports({
+      now: '2026-07-01T00:00:00.000Z',
+      bundles,
+    });
+
+    expect(bundles).toEqual([]);
+    expect(reports).toEqual([]);
+    expect(service.missingTranscriptWorkIds(snapshot)).toEqual(['work-1']);
+  });
+
+  it('converts parsed work reports into analyzed work report evidence refs', () => {
+    const reports = service.normalizeParsedWorkReports({
+      now: '2026-07-01T00:00:00.000Z',
+      sourceMode: 'llm',
+      parsedReports: [{
+        workId: 'work-1',
+        reports: [{
+          kind: 'operation',
+          headline: '新手真实卡点集中',
+          summary: '评论集中在工具安装顺序和选择成本。',
+          evidenceSignals: ['收藏高', '评论多为安装问题'],
+          strategyJudgment: '适合做新手避坑和工具选择栏目。',
+          supportingEvidenceIds: ['ev_comment_1', 'ev_comment_2'],
+          confidence: 'high',
+          gaps: [],
+        }],
+      }],
+      bundles: service.buildWorkEvidenceBundles(snapshotFixture(), rawRefsFixture()),
+    });
+    const evidenceRefs = service.toWorkReportEvidenceRefs(reports);
+
+    expect(reports[0].id).toBe('work_report_work-1');
+    expect(evidenceRefs[0]).toMatchObject({
+      id: 'work_report_work-1',
+      sourceType: 'owned_work',
+      sourceId: 'work-1',
+      analyzed: true,
+      stage: 'work_report',
+    });
+    expect(evidenceRefs[0].reason).toContain('适合做新手避坑');
+  });
+
+  it('builds local fallback work reports without pretending they are LLM analysis', () => {
+    const reports = service.buildLocalFallbackWorkReports({
+      now: '2026-07-01T00:00:00.000Z',
+      bundles: service.buildWorkEvidenceBundles(snapshotFixture(), rawRefsFixture()),
+    });
+    const evidenceRefs = service.toWorkReportEvidenceRefs(reports);
+
+    expect(reports[0].sourceMode).toBe('local_fallback');
+    expect(evidenceRefs[0].analyzed).toBe(false);
+    expect(evidenceRefs[0].reason).toContain('未经过 LLM 独立判断');
+  });
+});
+
+function snapshotFixture(): IpAccountSnapshot {
+  return {
+    id: 'snapshot-1',
+    accountId: 'account-1',
+    capturedAt: '2026-07-01T00:00:00.000Z',
+    dataMode: 'data_diagnosis',
+    warnings: [],
+    evidenceItemIds: [],
+    profile: {
+      nickname: 'FredTalk',
+      signature: 'Vibe Coding 实操',
+      followerCount: 1000,
+      followingCount: 10,
+      totalFavorited: 2000,
+      awemeCount: 10,
+    },
+    works: [{
+      id: 'work-1',
+      accountId: 'account-1',
+      awemeId: 'aweme-1',
+      title: '新手小白安装这六个 Skills 就行够了',
+      desc: '工具安装顺序和选择建议',
+      publishTime: '2026-06-30T00:00:00.000Z',
+      transcript: {
+        text: '新手先判断任务,再选择对应工具。视频逐步讲了安装顺序、常见误区和每个工具的使用边界。',
+        source: 'asr',
+        confidence: 'high',
+        capturedAt: '2026-07-01T00: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: '2026-07-01T00:00:00.000Z',
+    }],
+  };
+}
+
+function rawRefsFixture(): IpAccountStrategyEvidenceRef[] {
+  return [
+    {
+      id: 'ev_work_1',
+      sourceType: 'owned_work',
+      sourceId: 'work-1',
+      label: '高互动作品 1',
+      quote: '新手小白安装这六个 Skills 就行够了',
+      reason: '互动分 120,赞 58850,评 1036',
+      stage: 'raw_context',
+      analyzed: false,
+    },
+    {
+      id: 'ev_comment_1',
+      sourceType: 'owned_comment',
+      sourceId: 'comment-1',
+      label: '重点评论',
+      quote: '刚开始不知道先用哪个工具',
+      reason: '原始评论样本',
+      stage: 'raw_context',
+      analyzed: false,
+    },
+    {
+      id: 'ev_comment_2',
+      sourceType: 'owned_comment',
+      sourceId: 'comment-2',
+      label: '重点评论',
+      quote: '安装了很多但是不知道流程',
+      reason: '原始评论样本',
+      stage: 'raw_context',
+      analyzed: false,
+    },
+  ];
+}

+ 180 - 0
src/app/services/ip-account-work-report.service.ts

@@ -0,0 +1,180 @@
+import { Injectable } from '@angular/core';
+import {
+  IpAccountSnapshot,
+  IpAccountStrategyEvidenceRef,
+  IpAccountStrategySourceMode,
+  IpAccountWorkEvidenceBundle,
+  IpAccountWorkReportBlock,
+  IpAccountWorkStrategyReport,
+  IpEvidenceConfidence,
+} from '../models/ip-operator.model';
+
+export interface ParsedIpAccountWorkReportInput {
+  id?: string;
+  workId: string;
+  reports: Array<Partial<IpAccountWorkReportBlock>>;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpAccountWorkReportService {
+  buildWorkEvidenceBundles(
+    snapshot: IpAccountSnapshot,
+    contextRefs: IpAccountStrategyEvidenceRef[],
+  ): IpAccountWorkEvidenceBundle[] {
+    const refsBySourceId = new Map(contextRefs.map((item) => [item.sourceId, item]));
+    return snapshot.works
+      .filter((work) => refsBySourceId.has(work.id) && this.hasTranscript(work.transcript?.text))
+      .slice(0, 3)
+      .map((work) => {
+        const workRef = refsBySourceId.get(work.id)!;
+        const transcriptText = this.compact(work.transcript?.text || '', 1800);
+        const comments = work.comments
+          .map((comment) => refsBySourceId.get(comment.id))
+          .filter((item): item is IpAccountStrategyEvidenceRef => !!item)
+          .slice(0, 12);
+        return {
+          workId: work.id,
+          workEvidenceRefId: workRef.id,
+          title: this.compact(work.title || work.desc || '未命名作品', 80),
+          description: this.compact(work.desc || work.title || '', 220),
+          transcriptText,
+          transcriptSource: work.transcript?.source || 'unknown',
+          transcriptConfidence: work.transcript?.confidence || 'medium',
+          transcriptSummary: this.compact(transcriptText, 320),
+          metricsSummary: [
+            `播放 ${work.metrics.playCount || 0}`,
+            `点赞 ${work.metrics.likeCount || 0}`,
+            `评论 ${work.metrics.commentCount || 0}`,
+            `收藏 ${work.metrics.collectCount || 0}`,
+            `分享 ${work.metrics.shareCount || 0}`,
+          ].join(' / '),
+          structureSummary: [
+            work.structure?.hook ? `开头:${work.structure.hook}` : '',
+            work.structure?.topic ? `主题:${work.structure.topic}` : '',
+            work.structure?.style ? `风格:${work.structure.style}` : '',
+            work.structure?.cta ? `转化:${work.structure.cta}` : '',
+          ].filter(Boolean).join(';') || '未识别结构',
+          commentEvidenceRefs: comments.map((ref) => ({
+            id: ref.id,
+            sourceId: ref.sourceId,
+            text: ref.quote,
+          })),
+        };
+      });
+  }
+
+  missingTranscriptWorkIds(snapshot: IpAccountSnapshot): string[] {
+    return snapshot.works
+      .filter((work) => !this.hasTranscript(work.transcript?.text))
+      .map((work) => work.id);
+  }
+
+  normalizeParsedWorkReports(input: {
+    now: string;
+    sourceMode: IpAccountStrategySourceMode;
+    parsedReports: ParsedIpAccountWorkReportInput[];
+    bundles: IpAccountWorkEvidenceBundle[];
+  }): IpAccountWorkStrategyReport[] {
+    const bundlesByWorkId = new Map(input.bundles.map((item) => [item.workId, item]));
+    return input.parsedReports.flatMap((parsed) => {
+      const bundle = bundlesByWorkId.get(String(parsed?.workId || ''));
+      if (!bundle) return [];
+      const reports = (parsed.reports || []).slice(0, 2).map((block, index): IpAccountWorkReportBlock => ({
+        id: this.compact(String(block.id || '').trim(), 80)
+          || `work_report_${bundle.workId}_${block.kind === 'structure' ? 'structure' : 'operation'}_${index + 1}`,
+        kind: block.kind === 'structure' ? 'structure' : 'operation',
+        headline: this.compact(String(block.headline || bundle.title || '').trim(), 48),
+        summary: this.compact(String(block.summary || bundle.structureSummary || '').trim(), 180),
+        evidenceSignals: this.toStringList(block.evidenceSignals).slice(0, 4),
+        strategyJudgment: this.compact(
+          String(block.strategyJudgment || '仅作为作品结构参考,需结合账号定位人工复核。').trim(),
+          180,
+        ),
+        supportingEvidenceIds: this.toStringList(block.supportingEvidenceIds).slice(0, 12),
+        confidence: this.confidence(block.confidence),
+        gaps: this.toStringList(block.gaps).slice(0, 4),
+      }));
+      return [{
+        id: `work_report_${bundle.workId}`,
+        workId: bundle.workId,
+        sourceEvidenceId: bundle.workEvidenceRefId,
+        title: bundle.title,
+        sourceMode: input.sourceMode,
+        reports: reports.length ? reports : this.localBlocks(bundle),
+        supportingCommentEvidenceIds: bundle.commentEvidenceRefs.map((item) => item.id),
+        createdAt: input.now,
+      }];
+    });
+  }
+
+  buildLocalFallbackWorkReports(input: {
+    now: string;
+    bundles: IpAccountWorkEvidenceBundle[];
+  }): IpAccountWorkStrategyReport[] {
+    return input.bundles.slice(0, 8).map((bundle) => ({
+      id: `work_report_${bundle.workId}`,
+      workId: bundle.workId,
+      sourceEvidenceId: bundle.workEvidenceRefId,
+      title: bundle.title,
+      sourceMode: 'local_fallback',
+      reports: this.localBlocks(bundle),
+      supportingCommentEvidenceIds: bundle.commentEvidenceRefs.map((item) => item.id),
+      createdAt: input.now,
+    }));
+  }
+
+  toWorkReportEvidenceRefs(reports: IpAccountWorkStrategyReport[]): IpAccountStrategyEvidenceRef[] {
+    return reports.map((report) => {
+      const operation = report.reports.find((item) => item.kind === 'operation') || report.reports[0];
+      const analyzed = report.sourceMode === 'llm';
+      return {
+        id: report.id,
+        sourceType: 'owned_work',
+        sourceId: report.workId,
+        label: analyzed
+          ? `作品级分析:${this.compact(report.title, 32)}`
+          : `作品采集归档:${this.compact(report.title, 32)}`,
+        quote: operation?.summary || report.title,
+        reason: analyzed
+          ? operation?.strategyJudgment || 'LLM 已完成作品级运营判断。'
+          : `本地仅记录文字稿、作品表现和评论样本;未经过 LLM 独立判断。${operation?.strategyJudgment || ''}`,
+        confidence: analyzed ? operation?.confidence || 'medium' : 'medium',
+        gaps: analyzed ? operation?.gaps || [] : ['未经过 LLM 作品级分析'],
+        analyzed,
+        stage: 'work_report',
+      };
+    });
+  }
+
+  private localBlocks(bundle: IpAccountWorkEvidenceBundle): IpAccountWorkReportBlock[] {
+    const commentSignals = bundle.commentEvidenceRefs.slice(0, 3).map((item) => item.text);
+    return [{
+      id: `work_report_${bundle.workId}_structure_1`,
+      kind: 'structure',
+      headline: '文字稿待分析',
+      summary: `已取得视频文字稿:${bundle.transcriptSummary}`,
+      evidenceSignals: [`文字稿来源:${bundle.transcriptSource}`, bundle.metricsSummary, ...commentSignals].slice(0, 4),
+      strategyJudgment: '当前仅完成文字稿与采集事实归档,不能替代 LLM 作品级分析。',
+      supportingEvidenceIds: [bundle.workEvidenceRefId, ...bundle.commentEvidenceRefs.map((item) => item.id)].slice(0, 12),
+      confidence: 'medium',
+      gaps: ['未经过 LLM 作品级分析'],
+    }];
+  }
+
+  private hasTranscript(value: unknown): boolean {
+    return String(value || '').replace(/\s+/g, ' ').trim().length >= 20;
+  }
+
+  private confidence(value: unknown): IpEvidenceConfidence {
+    return value === 'high' || value === 'medium' || value === 'low' ? value : 'medium';
+  }
+
+  private toStringList(value: unknown): string[] {
+    return Array.isArray(value) ? value.map((item) => String(item || '').trim()).filter(Boolean) : [];
+  }
+
+  private compact(value: string, max: number): string {
+    const normalized = String(value || '').replace(/\s+/g, ' ').trim();
+    return normalized.length > max ? `${normalized.slice(0, Math.max(0, max - 1))}…` : normalized;
+  }
+}

+ 107 - 0
src/app/services/ip-content-production-flow.service.spec.ts

@@ -0,0 +1,107 @@
+import { TestBed } from '@angular/core/testing';
+import { IpScriptWorkbenchService } from './ip-script-workbench.service';
+import { IpContentProductionFlowService } from './ip-content-production-flow.service';
+
+describe('IpContentProductionFlowService', () => {
+  let service: IpContentProductionFlowService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [IpContentProductionFlowService, IpScriptWorkbenchService],
+    });
+    service = TestBed.inject(IpContentProductionFlowService);
+  });
+
+  it('creates script, calendar item and publish package draft from selected topic', () => {
+    const result = service.createProductionDraft({
+      accountId: 'account_1',
+      accountName: 'FredTalk',
+      positioningVersionId: 'positioning_1',
+      directionId: 'direction_growth',
+      topic: {
+        id: 'topic_1',
+        title: '新手第一次用 AI 工具应该先做哪三件事',
+        directionId: 'direction_growth',
+        targetAudience: 'AI 工具新手',
+        userProblem: '不知道从哪开始',
+        evidenceRefs: ['ev_1'],
+        viralPatternRefs: ['三步清单'],
+      },
+      startDateIso: '2026-06-16',
+      now: '2026-06-16T00:00:00.000Z',
+    });
+
+    expect(result.script.topicId).toBe('topic_1');
+    expect(result.script.shotList?.[0].startSecond).toBe(0);
+    expect(result.calendarItem.topicId).toBe('topic_1');
+    expect(result.calendarItem.scriptId).toBe(result.script.id);
+    expect(result.publishPackage.topicId).toBe('topic_1');
+    expect(result.publishPackage.scriptId).toBe(result.script.id);
+    expect(result.publishPackage.status).toBe('draft');
+    expect(result.publishPackage.evidenceItemIds).toEqual(['ev_1']);
+    expect(result.publishPackage.riskChecklist.map((item) => item.label).join(' ')).toContain('不自动发布');
+  });
+
+  it('creates review task after publish package is bound to real work', () => {
+    const task = service.createReviewTaskAfterBinding({
+      accountId: 'account_1',
+      publishPackageId: 'pack_1',
+      awemeId: 'aweme_1',
+      metrics: { likeCount: 10, commentCount: 2, collectCount: 5, shareCount: 1 },
+      now: '2026-06-16T00:00:00.000Z',
+    });
+
+    expect(task.type).toBe('review');
+    expect(task.column).toBe('this_week');
+    expect(task.title).toContain('复盘');
+    expect(task.reason).toContain('互动分');
+  });
+
+  it('reuses the confirmed LLM script in calendar and publish package instead of rebuilding a fallback', () => {
+    const result = service.createProductionDraft({
+      accountId: 'account_1',
+      accountName: 'FredTalk',
+      positioningVersionId: 'positioning_1',
+      directionId: 'direction_trust',
+      script: {
+        id: 'llm_script_1',
+        topicId: 'topic_1',
+        type: 'full',
+        title: 'LLM 执行脚本',
+        hook: '先给结论',
+        painPoint: '不知道第一步做什么',
+        viewpoint: '先写清关注理由',
+        caseOrMethod: '展示主页修改前后',
+        goldenSentence: '看懂价值,才会关注。',
+        closingCta: '留言行业',
+        fullScript: '这是经过账号证据生成的 LLM 完整口播。',
+        subtitleHighlights: ['先写清关注理由'],
+        shotList: [{
+          id: 'shot_1',
+          startSecond: 0,
+          endSecond: 30,
+          segmentTitle: '完整演示',
+          visualDesign: '账号主页修改前后对比',
+          narration: '这是经过账号证据生成的 LLM 完整口播。',
+          subtitle: '先写清关注理由',
+          actionNotes: '指向前后差异',
+          audioNotes: '纯口播',
+        }],
+        recommendedDurationSeconds: 30,
+      },
+      topic: {
+        id: 'topic_1',
+        title: '老板 IP 第一步',
+        userProblem: '不知道第一步做什么',
+      },
+      startDateIso: '2026-07-06',
+      now: '2026-07-06T00:00:00.000Z',
+    });
+
+    expect(result.script.id).toBe('llm_script_1');
+    expect(result.calendarItem.scriptId).toBe('llm_script_1');
+    expect(result.publishPackage.scriptId).toBe('llm_script_1');
+    expect(result.publishPackage.scriptText).toContain('LLM 完整口播');
+    expect((result.publishPackage.shootingChecklist || []).join(' ')).toContain('账号主页修改前后对比');
+  });
+});

+ 221 - 0
src/app/services/ip-content-production-flow.service.ts

@@ -0,0 +1,221 @@
+import { Injectable, inject } from '@angular/core';
+import {
+  IpContentCalendarItem,
+  IpContentDirectionRole,
+  IpEvidencePlatform,
+  IpOperationTask,
+  IpPublishPackage,
+  IpScript,
+} from '../models/ip-operator.model';
+import { IpScriptWorkbenchService } from './ip-script-workbench.service';
+
+export interface CreateProductionDraftInput {
+  accountId: string;
+  accountName: string;
+  positioningVersionId: string;
+  directionId: string;
+  directionRole?: IpContentDirectionRole;
+  platform?: IpEvidencePlatform;
+  topic: {
+    id: string;
+    title: string;
+    directionId?: string;
+    targetAudience?: string;
+    userProblem?: string;
+    contentGoal?: string;
+    evidenceRefs?: string[];
+    viralPatternRefs?: string[];
+    requiredMaterials?: string[];
+  };
+  script?: IpScript;
+  startDateIso: string;
+  now?: string;
+}
+
+export interface ProductionDraftResult {
+  script: IpScript;
+  calendarItem: IpContentCalendarItem;
+  publishPackage: IpPublishPackage;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpContentProductionFlowService {
+  private readonly scriptWorkbench = inject(IpScriptWorkbenchService);
+
+  createProductionDraft(input: CreateProductionDraftInput): ProductionDraftResult {
+    const now = input.now || new Date().toISOString();
+    const platform = input.platform || 'douyin';
+    const directionRole = input.directionRole || this.directionRoleFromId(input.directionId);
+    const topicGoal = input.topic.userProblem || input.topic.contentGoal || '验证当前账号定位和用户反馈';
+    const script = input.script || this.createFallbackScript(input, directionRole, topicGoal);
+    const scriptId = script.id;
+
+    const calendarItem: IpContentCalendarItem = {
+      id: `calendar_${input.topic.id}_${Date.now()}`,
+      date: input.startDateIso,
+      platform,
+      accountName: input.accountName,
+      topicId: input.topic.id,
+      scriptId,
+      title: input.topic.title,
+      contentType: directionRole,
+      goal: topicGoal,
+      status: 'drafting',
+      materialStatus: (input.topic.requiredMaterials || []).length ? 'missing' : 'partial',
+      evidenceItemIds: this.unique(input.topic.evidenceRefs || []),
+      notes: '用户主动选择选题后创建,发布前必须人工确认。',
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    const publishPackage: IpPublishPackage = {
+      id: `publish_pack_${input.topic.id}_${Date.now()}`,
+      calendarItemId: calendarItem.id,
+      topicId: input.topic.id,
+      scriptId,
+      platform,
+      accountName: input.accountName,
+      titleOptions: this.unique([
+        input.topic.title,
+        input.topic.targetAudience ? `${input.topic.targetAudience}最该先做的 3 件事` : '',
+        topicGoal ? `别急着拍,先解决${this.truncate(topicGoal, 18)}` : '',
+      ]).slice(0, 5),
+      coverSuggestions: [
+        `封面主文案:${this.truncate(topicGoal, 20)}`,
+        '构图:真人半身 + 关键问题字幕 + 3 个步骤数字',
+        '首帧必须和开头 3 秒口播一致。',
+      ],
+      caption: [
+        input.topic.title,
+        input.topic.targetAudience ? `适合:${input.topic.targetAudience}` : '',
+        `问题:${topicGoal}`,
+        '发布方式:人工确认后手动发布。',
+      ].filter(Boolean).join('\n'),
+      scriptText: script.fullScript || '',
+      videoGoal: this.videoGoal(directionRole),
+      durationAndRhythm: `${script.recommendedDurationSeconds || 60} 秒;前 3 秒抛问题,中段给步骤,结尾引导评论`,
+      shootingChecklist: script.shotList?.map((shot) => `${shot.startSecond}-${shot.endSecond}秒:${shot.visualDesign}`) || [],
+      subtitleHighlights: script.subtitleHighlights || [],
+      audioSuggestion: directionRole === 'trust' ? '低音量轻音乐或纯口播,突出案例可信度。' : '轻节奏背景音,口播优先清晰。',
+      hashtags: this.unique(['IP运营', '内容选题', platform, ...(input.topic.viralPatternRefs || [])]).slice(0, 10),
+      commentGuidance: '你现在卡在哪一步?评论区告诉我,我按场景拆。',
+      pinnedCommentSuggestion: '把你的具体场景发出来,我优先挑高频问题做下一条。',
+      postPublishMonitoringTargets: ['播放', '评论', '收藏', '分享', '新粉'],
+      materialFiles: (input.topic.requiredMaterials || []).map((name) => ({
+        id: `material_${Date.now()}_${this.slug(name)}`,
+        name,
+        status: 'missing',
+      })),
+      scheduleSuggestion: input.startDateIso,
+      productLink: '',
+      productTitle: '',
+      isOriginal: true,
+      isDraft: true,
+      materialChecklist: [
+        { id: 'mat_real_scene', label: '真人出镜或真实操作画面', checked: false, required: true },
+        { id: 'mat_screen', label: '工具界面截图、录屏或客户案例素材', checked: false, required: true },
+      ],
+      riskChecklist: [
+        { id: 'risk_no_auto_publish', label: '发布包只用于人工发布,不自动发布、自动评论或自动私信', checked: false, required: true },
+        { id: 'risk_no_copy', label: '未照搬对标账号原文、标题和案例', checked: false, required: true },
+        { id: 'risk_no_overpromise', label: '无夸大收益承诺', checked: false, required: true },
+      ],
+      evidenceItemIds: this.unique(input.topic.evidenceRefs || []),
+      painInsightIds: [],
+      polishSource: 'rule_draft',
+      status: 'draft',
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    return { script, calendarItem, publishPackage };
+  }
+
+  private createFallbackScript(
+    input: CreateProductionDraftInput,
+    directionRole: IpContentDirectionRole,
+    topicGoal: string,
+  ): IpScript {
+    const fallback = this.scriptWorkbench.buildFallbackScript({
+      title: input.topic.title,
+      directionRole,
+      contentGoal: topicGoal,
+    });
+    return {
+      id: `script_${input.topic.id}_${Date.now()}`,
+      topicId: input.topic.id,
+      type: 'full',
+      title: `${input.topic.title}|分镜版`,
+      hook: fallback.shots[0]?.narration || input.topic.title,
+      painPoint: topicGoal,
+      viewpoint: fallback.shots[2]?.narration || input.topic.title,
+      caseOrMethod: fallback.shots[3]?.narration || input.topic.title,
+      goldenSentence: '先让用户知道你在帮谁,再让用户相信你能帮他。',
+      closingCta: fallback.shots[fallback.shots.length - 1]?.narration || '评论区留下你最卡的问题。',
+      fullScript: fallback.shots.map((shot) => `${shot.startSecond}-${shot.endSecond}秒 ${shot.narration}`).join('\n'),
+      shootingTips: fallback.shots.map((shot) => `${shot.startSecond}-${shot.endSecond}秒:${shot.visualDesign}`),
+      subtitleHighlights: fallback.shots.map((shot) => shot.subtitle).filter(Boolean),
+      replaceableSlots: ['真实账号画面', '客户案例截图', '评论区问题'],
+      riskNote: fallback.riskNotes.join(';'),
+      qualityLevel: 'polish_first',
+      scriptSummary: '当前为本地兜底草稿,需要先完成人工打磨。',
+      productionNotes: fallback.optimizationSuggestions,
+      shotList: fallback.shots,
+      recommendedDurationSeconds: fallback.recommendedDurationSeconds,
+      durationReason: fallback.durationReason,
+      userEdited: false,
+    };
+  }
+
+  createReviewTaskAfterBinding(input: {
+    accountId: string;
+    publishPackageId: string;
+    awemeId: string;
+    metrics: { likeCount?: number; commentCount?: number; collectCount?: number; shareCount?: number };
+    now?: string;
+  }): IpOperationTask {
+    const now = input.now || new Date().toISOString();
+    const impact =
+      Number(input.metrics.likeCount || 0)
+      + Number(input.metrics.commentCount || 0) * 4
+      + Number(input.metrics.collectCount || 0) * 3
+      + Number(input.metrics.shareCount || 0) * 5;
+    return {
+      id: `task_review_${input.publishPackageId}_${input.awemeId}`,
+      accountId: input.accountId,
+      column: 'this_week',
+      type: 'review',
+      title: `复盘已发布作品 ${input.awemeId}`,
+      reason: `作品已绑定真实数据,当前互动分 ${impact}。需要判断开头、选题和评论反馈是否支持下一轮定位或选题调整。`,
+      growthImpactScore: Math.min(95, Math.max(60, impact > 100 ? 88 : 70)),
+      relatedEvidenceIds: [],
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private directionRoleFromId(directionId: string): IpContentDirectionRole {
+    if (/trust|信任|案例/.test(directionId)) return 'trust';
+    if (/interaction|互动|评论/.test(directionId)) return 'interaction';
+    return 'growth';
+  }
+
+  private videoGoal(role: IpContentDirectionRole): string[] {
+    if (role === 'trust') return ['收藏', '信任', '咨询'];
+    if (role === 'interaction') return ['评论', '问题反馈', '系列选题'];
+    return ['播放', '新粉', '转发'];
+  }
+
+  private unique(values: string[]): string[] {
+    return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))];
+  }
+
+  private truncate(value: string, length: number): string {
+    const text = String(value || '').trim();
+    return text.length > length ? `${text.slice(0, length)}...` : text;
+  }
+
+  private slug(value: string): string {
+    return String(value || '').replace(/\s+/g, '_').replace(/[^\w-]/g, '').slice(0, 16) || 'asset';
+  }
+}

+ 54 - 0
src/app/services/ip-operator-assistant-orchestrator.service.spec.ts

@@ -0,0 +1,54 @@
+import { TestBed } from '@angular/core/testing';
+import { IpAssistantAction } from '../models/ip-operator.model';
+import { IpOperatorAssistantOrchestratorService } from './ip-operator-assistant-orchestrator.service';
+
+describe('IpOperatorAssistantOrchestratorService', () => {
+  let service: IpOperatorAssistantOrchestratorService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [IpOperatorAssistantOrchestratorService],
+    });
+    service = TestBed.inject(IpOperatorAssistantOrchestratorService);
+  });
+
+  it('classifies assistant actions by platform risk', () => {
+    expect(service.classifyActionRisk({ type: 'generate_topics' })).toBe('low');
+    expect(service.classifyActionRisk({ type: 'accept_positioning_version' })).toBe('medium');
+    expect(service.classifyActionRisk({ type: 'auto_publish' })).toBe('blocked');
+  });
+
+  it('builds assistant context from current IP operator page state', () => {
+    const context = service.buildPageContext({
+      accountName: 'FredTalk',
+      currentPageSection: '方向与选题',
+      positioningVersion: { persona: 'AI 工具陪跑者', targetAudience: 'AI 工具新手' },
+      directions: [{ title: '入门路径方向', role: 'growth' }],
+      selectedTopic: { title: '新手第一次用 AI 工具应该先做哪三件事' },
+      publishPackage: null,
+    });
+
+    expect(context).toContain('当前账号:FredTalk');
+    expect(context).toContain('当前页面:方向与选题');
+    expect(context).toContain('AI 工具陪跑者');
+    expect(context).toContain('自动发布');
+  });
+
+  it('routes assistant action by risk level', () => {
+    expect(service.routeAction(actionFixture('a1', 'generate_topics')).mode).toBe('execute');
+    expect(service.routeAction(actionFixture('a2', 'approve_publish_package')).mode).toBe('confirm');
+    expect(service.routeAction(actionFixture('a3', 'auto_comment')).mode).toBe('reject');
+  });
+});
+
+function actionFixture(id: string, type: IpAssistantAction['type']): IpAssistantAction {
+  return {
+    id,
+    type,
+    label: type,
+    payload: {},
+    risk: 'low',
+    reason: '测试动作',
+    createdAt: '2026-06-15T00:00:00.000Z',
+  };
+}

+ 83 - 0
src/app/services/ip-operator-assistant-orchestrator.service.ts

@@ -0,0 +1,83 @@
+import { Injectable } from '@angular/core';
+import {
+  IpAssistantAction,
+  IpAssistantActionRisk,
+  IpAssistantActionType,
+} from '../models/ip-operator.model';
+
+export interface IpAssistantPageContextInput {
+  accountName: string;
+  currentPageSection: string;
+  positioningVersion: { persona: string; targetAudience: string } | null;
+  directions: Array<{ title: string; role: string }>;
+  selectedTopic: { title: string } | null;
+  publishPackage: { id: string; status: string } | null;
+}
+
+export interface IpAssistantActionRoute {
+  mode: 'execute' | 'confirm' | 'reject';
+  message: string;
+  risk: IpAssistantActionRisk;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpOperatorAssistantOrchestratorService {
+  private readonly lowRiskActions = new Set<IpAssistantActionType>([
+    'generate_topics',
+    'generate_script',
+    'create_task',
+    'add_calendar_draft',
+    'summarize_pain',
+    'generate_direction',
+    'open_workbench',
+  ]);
+
+  private readonly mediumRiskActions = new Set<IpAssistantActionType>([
+    'accept_positioning_version',
+    'overwrite_script',
+    'batch_adjust_calendar',
+    'approve_publish_package',
+    'mark_weekly_focus',
+  ]);
+
+  classifyActionRisk(action: Pick<IpAssistantAction, 'type'>): IpAssistantActionRisk {
+    if (this.lowRiskActions.has(action.type)) return 'low';
+    if (this.mediumRiskActions.has(action.type)) return 'medium';
+    return 'blocked';
+  }
+
+  routeAction(action: IpAssistantAction): IpAssistantActionRoute {
+    const risk = this.classifyActionRisk(action);
+    if (risk === 'low') {
+      return { mode: 'execute', risk, message: '低风险运营动作,可直接执行。' };
+    }
+    if (risk === 'medium') {
+      return {
+        mode: 'confirm',
+        risk,
+        message: '该动作会影响定位、脚本、日历或发布决策,需要用户二次确认。',
+      };
+    }
+    return {
+      mode: 'reject',
+      risk,
+      message: '该动作涉及自动发布、自动互动或平台风控风险,当前版本禁止执行。',
+    };
+  }
+
+  buildPageContext(input: IpAssistantPageContextInput): string {
+    const directions = input.directions
+      .map((item) => `${item.role}:${item.title}`)
+      .filter(Boolean)
+      .join(';');
+    return [
+      `当前账号:${input.accountName || '未选择账号'}`,
+      `当前页面:${input.currentPageSection || 'IP操盘工作台'}`,
+      `当前定位:${input.positioningVersion ? `${input.positioningVersion.persona} / ${input.positioningVersion.targetAudience}` : '尚未确认定位版本'}`,
+      `内容方向:${directions || '暂无方向'}`,
+      `当前选题:${input.selectedTopic?.title || '未选择选题'}`,
+      `发布包:${input.publishPackage ? `${input.publishPackage.id} / ${input.publishPackage.status}` : '暂无发布包'}`,
+      '助手边界:低风险动作可直接执行;确认定位、覆盖脚本、批量调整日历、批准发布包等动作必须二次确认;自动发布、自动评论、自动私信、Cookie 池、代理池禁止执行。',
+    ].join('\n');
+  }
+}

+ 57 - 0
src/app/services/ip-operator-comment-label.service.spec.ts

@@ -0,0 +1,57 @@
+import { IpEvidenceItem } from '../models/ip-operator.model';
+import { IpOperatorCommentLabelService } from './ip-operator-comment-label.service';
+
+describe('IpOperatorCommentLabelService', () => {
+  const service = new IpOperatorCommentLabelService();
+
+  it('parses fenced label-system JSON and insight JSON', () => {
+    const labelSystem = service.parseLabelSystem(`\`\`\`json
+{
+  "name": "IP labels",
+  "personaSummary": "Skeptical owner audience",
+  "labels": [
+    {"id":"l1","level1":"question","level2":"proof","level3":"asks for cases","description":"Needs examples"}
+  ]
+}
+\`\`\``, ['evidence-1'], '2026-06-11T00:00:00.000Z');
+    const insights = service.parseInsights(JSON.stringify({
+      insights: [{
+        title: 'Need real proof',
+        category: 'question',
+        severity: 'high',
+        summary: 'Users ask for proof before trusting advice.',
+        representativeComments: ['Do you have real examples?'],
+      }],
+    }), ['evidence-1'], labelSystem.id, '2026-06-11T00:00:00.000Z');
+
+    expect(labelSystem.labels[0].level3).toBe('asks for cases');
+    expect(insights[0].labelSystemId).toBe(labelSystem.id);
+    expect(insights[0].severity).toBe('high');
+  });
+
+  it('bounds comment samples before building LLM prompts', () => {
+    const evidence: IpEvidenceItem = {
+      id: 'evidence-1',
+      platform: 'manual',
+      kind: 'comment',
+      bucket: 'topics',
+      title: 'Large comment block',
+      rawText: Array.from({ length: 150 }, (_, index) => `How should owner IP explain proof ${index}?`).join('\n'),
+      summary: 'Large sample',
+      tags: [],
+      relatedTopicIds: [],
+      relatedScriptIds: [],
+      evidenceRefs: [],
+      selectedForGeneration: true,
+      confidence: 'high',
+      createdAt: '2026-06-11T00:00:00.000Z',
+      updatedAt: '2026-06-11T00:00:00.000Z',
+    };
+
+    const samples = service.commentSamples([evidence]);
+    const prompt = service.buildLabelSystemPrompt([evidence]);
+
+    expect(samples.length).toBe(100);
+    expect(prompt.length).toBeLessThan(25_000);
+  });
+});

+ 143 - 0
src/app/services/ip-operator-comment-label.service.ts

@@ -0,0 +1,143 @@
+import { Injectable } from '@angular/core';
+import {
+  IpCommentLabelSystem,
+  IpCommentPainInsight,
+  IpEvidenceItem,
+  IpPainCategory,
+  IpPainSeverity,
+} from '../models/ip-operator.model';
+
+export interface IpCommentLabelGeneration {
+  labelSystem: IpCommentLabelSystem;
+  insights: IpCommentPainInsight[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpOperatorCommentLabelService {
+  buildLabelSystemPrompt(evidenceItems: IpEvidenceItem[]): string {
+    const comments = this.commentSamples(evidenceItems);
+    return [
+      '请为 IP 内容运营评论分析生成一个三层标签体系,只输出 JSON。',
+      '标签要适配个人品牌/自媒体/IP 操盘,不要只按电商售前字段分类。',
+      'JSON 字段:name, personaSummary, labels。labels 每项包含 id, level1, level2, level3, description。',
+      '最多输出 24 个标签,必须覆盖问题、反对意见、风险、价格价值、实操教程、人群身份、购买意向、情绪共鸣。',
+      `评论样本 JSON:${JSON.stringify(comments, null, 2)}`,
+    ].join('\n\n');
+  }
+
+  buildAssignmentPrompt(labelSystem: IpCommentLabelSystem, evidenceItems: IpEvidenceItem[]): string {
+    const comments = this.commentSamples(evidenceItems);
+    return [
+      '请基于给定标签体系,把评论样本聚合成 IP 内容运营痛点洞察,只输出 JSON。',
+      'JSON 字段:insights。每个 insight 包含 title, category, severity, summary, commentCount, keywordFrequency, representativeComments, suggestedTopics, scriptAngles, riskBoundaries。',
+      'category 只能是 question, objection, risk, price_value, how_to, identity, purchase_intent, resonance。',
+      'severity 只能是 high, medium, low。不要生成自动回复话术,只生成选题/脚本/风险建议。',
+      `标签体系 JSON:${JSON.stringify(labelSystem, null, 2)}`,
+      `评论样本 JSON:${JSON.stringify(comments, null, 2)}`,
+    ].join('\n\n');
+  }
+
+  parseLabelSystem(raw: string, evidenceItemIds: string[], now = new Date().toISOString()): IpCommentLabelSystem {
+    const parsed = this.parseJson(raw);
+    const labelsRaw = Array.isArray(parsed?.labels) ? parsed.labels : [];
+    const labels = labelsRaw.map((item: any, index: number) => ({
+      id: String(item?.id || `label_${index + 1}`),
+      level1: String(item?.level1 || item?.category || '痛点').trim(),
+      level2: String(item?.level2 || item?.subcategory || '未分类').trim(),
+      level3: String(item?.level3 || item?.name || `标签 ${index + 1}`).trim(),
+      description: String(item?.description || '').trim(),
+    })).filter((item: any) => item.level1 && item.level2 && item.level3).slice(0, 24);
+    if (!labels.length) throw new Error('LLM comment label system is empty.');
+    return {
+      id: `ip_label_system_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+      name: String(parsed?.name || 'IP comment label system').trim(),
+      evidenceItemIds,
+      labels,
+      personaSummary: String(parsed?.personaSummary || parsed?.persona || '').trim(),
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  parseInsights(raw: string, evidenceItemIds: string[], labelSystemId: string, now = new Date().toISOString()): IpCommentPainInsight[] {
+    const parsed = this.parseJson(raw);
+    const rawItems = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.insights) ? parsed.insights : [];
+    return rawItems.map((item: any, index: number) => {
+      const category = this.category(item?.category);
+      const representativeComments = this.textList(item?.representativeComments || item?.comments).slice(0, 5);
+      const summary = String(item?.summary || '').trim();
+      if (!category || !summary) return null;
+      return {
+        id: `ip_pain_llm_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`,
+        title: String(item?.title || `${category} insight`).trim(),
+        category,
+        severity: this.severity(item?.severity),
+        summary,
+        commentCount: Math.max(1, Number(item?.commentCount || representativeComments.length || 1)),
+        keywordFrequency: this.keywordFrequency(item?.keywordFrequency),
+        representativeComments,
+        evidenceItemIds,
+        suggestedTopics: this.textList(item?.suggestedTopics).slice(0, 6),
+        scriptAngles: this.textList(item?.scriptAngles).slice(0, 6),
+        riskBoundaries: this.textList(item?.riskBoundaries).slice(0, 6),
+        labelSystemId,
+        createdAt: now,
+        updatedAt: now,
+      } satisfies IpCommentPainInsight;
+    }).filter((item: IpCommentPainInsight | null): item is IpCommentPainInsight => !!item).slice(0, 20);
+  }
+
+  commentSamples(evidenceItems: IpEvidenceItem[]): Array<{ evidenceItemId: string; text: string }> {
+    const samples: Array<{ evidenceItemId: string; text: string }> = [];
+    let totalChars = 0;
+    for (const item of evidenceItems) {
+      const comments = String(item.rawText || '')
+        .split(/[\r\n。!?!?;;]+/)
+        .map((text) => text.trim())
+        .filter((text) => text.length >= 4);
+      for (const text of comments) {
+        if (samples.length >= 100 || totalChars >= 20_000) return samples;
+        const clipped = text.slice(0, 220);
+        samples.push({ evidenceItemId: item.id, text: clipped });
+        totalChars += clipped.length;
+      }
+    }
+    return samples;
+  }
+
+  private parseJson(raw: string): any {
+    const text = String(raw || '').trim().replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim();
+    try {
+      return JSON.parse(text);
+    } catch {
+      const match = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
+      if (!match) throw new Error('LLM comment label output is not JSON.');
+      return JSON.parse(match[0]);
+    }
+  }
+
+  private category(value: unknown): IpPainCategory | null {
+    const text = String(value || '').trim() as IpPainCategory;
+    return ['question', 'objection', 'risk', 'price_value', 'how_to', 'identity', 'purchase_intent', 'resonance'].includes(text)
+      ? text
+      : null;
+  }
+
+  private severity(value: unknown): IpPainSeverity {
+    const text = String(value || '').trim();
+    return text === 'high' || text === 'medium' || text === 'low' ? text : 'medium';
+  }
+
+  private textList(value: unknown): string[] {
+    if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean);
+    return String(value || '').split(/[,,;;\n]+/).map((item) => item.trim()).filter(Boolean);
+  }
+
+  private keywordFrequency(value: unknown): Array<{ keyword: string; count: number }> {
+    if (!Array.isArray(value)) return [];
+    return value.map((item: any) => ({
+      keyword: String(item?.keyword || item?.word || '').trim(),
+      count: Math.max(1, Number(item?.count || 1)),
+    })).filter((item) => item.keyword).slice(0, 12);
+  }
+}

+ 32 - 0
src/app/services/ip-operator-comment-pain.service.spec.ts

@@ -0,0 +1,32 @@
+import { IpOperatorCommentPainService } from './ip-operator-comment-pain.service';
+
+describe('IpOperatorCommentPainService', () => {
+  const service = new IpOperatorCommentPainService();
+
+  it('groups account comments into pain insights with evidence ids', () => {
+    const insights = service.generatePainInsightsFromAccountComments('plan_1', [
+      { id: 'ev_c1', text: '收藏了但不知道怎么开始', workTitle: 'Skills 入门' },
+      { id: 'ev_c2', text: '装了很多工具还是不知道先用哪个', workTitle: 'Skills 入门' },
+      { id: 'ev_c3', text: '能不能给一个普通人可执行的顺序', workTitle: 'AI 入门' },
+    ], '2026-06-15T00:00:00.000Z');
+
+    expect(insights.length).toBeGreaterThan(0);
+    expect(insights[0].evidenceItemIds.length).toBeGreaterThan(0);
+    expect(insights.map((item) => item.summary).join(' ')).toContain('评论');
+    expect(insights.flatMap((item) => item.representativeComments).join(' ')).toContain('不知道');
+  });
+
+  it('keeps stable pain insight ids when the same comment evidence is analyzed repeatedly', () => {
+    const evidence = [
+      { id: 'ev_c1', text: '怎么开始做账号,能不能给一个流程', workTitle: '账号入门' },
+      { id: 'ev_c2', text: '有没有步骤和模板可以参考', workTitle: '账号入门' },
+      { id: 'ev_c3', text: '新手应该先做什么', workTitle: '账号入门' },
+    ];
+
+    const first = service.generatePainInsightsFromAccountComments('plan_1', evidence, '2026-06-15T00:00:00.000Z');
+    const second = service.generatePainInsightsFromAccountComments('plan_1', evidence, '2026-06-16T00:00:00.000Z');
+
+    expect(first.length).toBeGreaterThan(0);
+    expect(second.map((item) => item.id)).toEqual(first.map((item) => item.id));
+  });
+});

+ 219 - 0
src/app/services/ip-operator-comment-pain.service.ts

@@ -0,0 +1,219 @@
+import { Injectable } from '@angular/core';
+import {
+  IpCommentPainInsight,
+  IpEvidenceItem,
+  IpPainCategory,
+  IpPainSeverity,
+} from '../models/ip-operator.model';
+
+interface PainRule {
+  category: IpPainCategory;
+  label: string;
+  keywords: string[];
+  suggestedTopics: string[];
+  scriptAngles: string[];
+  riskBoundaries: string[];
+}
+
+const PAIN_RULES: PainRule[] = [
+  {
+    category: 'question',
+    label: '用户追问',
+    keywords: ['怎么', '如何', '为什么', '能不能', '有没有', '哪里', '什么', '?', '?', 'how', 'what', 'why', 'where', 'can i', 'do you have'],
+    suggestedTopics: ['集中回答评论区最高频问题', '把一个追问拆成操作清单'],
+    scriptAngles: ['用评论原话开头', '先承认困惑,再给判断标准'],
+    riskBoundaries: ['不要把单条评论放大成普遍结论', '涉及专业建议时保留适用前提'],
+  },
+  {
+    category: 'objection',
+    label: '反对意见',
+    keywords: ['不信', '假的', '忽悠', '割韭菜', '没用', '太难', '做不到', '不适合'],
+    suggestedTopics: ['正面拆解用户不信任的原因', '用真实案例回应常见反对意见'],
+    scriptAngles: ['先替用户说出反对意见', '用边界和案例降低防御感'],
+    riskBoundaries: ['不要攻击用户或竞品', '不要承诺所有人都能复制结果'],
+  },
+  {
+    category: 'risk',
+    label: '风险担忧',
+    keywords: ['风险', '踩坑', '被骗', '违规', '封号', '侵权', '焦虑', '怕'],
+    suggestedTopics: ['整理一份避坑清单', '把风险边界讲清楚'],
+    scriptAngles: ['用错误示范切入', '给出可检查的安全边界'],
+    riskBoundaries: ['不要提供绕风控方案', '不要鼓励批量自动化互动'],
+  },
+  {
+    category: 'price_value',
+    label: '价格与价值判断',
+    keywords: ['贵', '多少钱', '价格', '值不值', '付费', '预算', '成本', '划算'],
+    suggestedTopics: ['解释服务价值和适用人群', '拆解投入产出判断方式'],
+    scriptAngles: ['先讲不适合谁,再讲适合谁', '用成本对比解释价值'],
+    riskBoundaries: ['不要夸大收益', '不要用焦虑制造购买压力'],
+  },
+  {
+    category: 'how_to',
+    label: '实操需求',
+    keywords: ['教程', '步骤', '流程', '模板', '方法', '清单', '实操', '案例', 'example', 'examples', 'template', 'step', 'process'],
+    suggestedTopics: ['做一条从 0 到 1 的流程内容', '把方法拆成模板或检查表'],
+    scriptAngles: ['三步法结构', '先给结论,再拆步骤'],
+    riskBoundaries: ['不要只给空泛口号', '需要标明前置条件和限制'],
+  },
+  {
+    category: 'identity',
+    label: '身份适配',
+    keywords: ['我这种', '新手', '小白', '老板', '宝妈', '普通人', '传统行业', '个人IP'],
+    suggestedTopics: ['按人群重写同一个选题', '解释不同阶段的账号定位差异'],
+    scriptAngles: ['用人群自我代入开头', '把建议按阶段分层'],
+    riskBoundaries: ['不要用单一人设覆盖所有人', '不要暗示身份决定结果'],
+  },
+  {
+    category: 'purchase_intent',
+    label: '购买意向',
+    keywords: ['怎么买', '哪里买', '私信', '报名', '咨询', '链接', '课程', '服务'],
+    suggestedTopics: ['补一条购买前判断标准', '解释咨询前需要准备什么'],
+    scriptAngles: ['用筛选标准替代硬广', '把 CTA 放在明确适用场景后'],
+    riskBoundaries: ['不要承诺保结果', '不要把评论区变成强销售话术'],
+  },
+  {
+    category: 'resonance',
+    label: '情绪共鸣',
+    keywords: ['太真实', '扎心', '共鸣', '就是我', '说中了', '焦虑', '崩溃', '终于'],
+    suggestedTopics: ['围绕共鸣情绪做系列表达', '把情绪背后的真实问题讲透'],
+    scriptAngles: ['先复述情绪,再给出出路', '用故事承接共鸣'],
+    riskBoundaries: ['不要制造恐慌', '情绪表达后必须回到可执行建议'],
+  },
+];
+
+@Injectable({ providedIn: 'root' })
+export class IpOperatorCommentPainService {
+  generatePainInsightsFromAccountComments(
+    planId: string,
+    comments: Array<{ id: string; text: string; workTitle: string }>,
+    now = new Date().toISOString(),
+  ): IpCommentPainInsight[] {
+    const evidenceItems: IpEvidenceItem[] = comments
+      .map((comment) => ({
+        id: comment.id,
+        platform: 'douyin' as const,
+        kind: 'comment' as const,
+        bucket: 'topics' as const,
+        title: comment.workTitle || `账号评论 ${planId}`,
+        rawText: String(comment.text || '').trim(),
+        summary: String(comment.text || '').trim(),
+        tags: ['账号评论', `方案:${planId}`],
+        relatedTopicIds: [],
+        relatedScriptIds: [],
+        evidenceRefs: [],
+        selectedForGeneration: true,
+        confidence: 'high' as const,
+        createdAt: now,
+        updatedAt: now,
+      }))
+      .filter((item) => item.rawText);
+    return this.analyze(evidenceItems, now);
+  }
+
+  analyze(evidenceItems: IpEvidenceItem[], now = new Date().toISOString()): IpCommentPainInsight[] {
+    const comments = evidenceItems
+      .filter((item) => item.kind === 'comment' || item.tags.some((tag) => /评论/.test(tag)))
+      .flatMap((item) =>
+      this.extractComments(item.rawText).map((text) => ({ text, evidenceItemId: item.id })),
+    );
+    const grouped = new Map<IpPainCategory, Array<{ text: string; evidenceItemId: string; matches: string[] }>>();
+
+    for (const comment of comments) {
+      const ruleMatches = PAIN_RULES
+        .map((rule) => ({ rule, matches: this.matchKeywords(comment.text, rule.keywords) }))
+        .filter((item) => item.matches.length);
+      const best = ruleMatches.sort((a, b) => b.matches.length - a.matches.length)[0];
+      if (!best) continue;
+      const current = grouped.get(best.rule.category) || [];
+      current.push({ ...comment, matches: best.matches });
+      grouped.set(best.rule.category, current);
+    }
+
+    return PAIN_RULES
+      .map((rule) => this.toInsight(rule, grouped.get(rule.category) || [], now))
+      .filter((item): item is IpCommentPainInsight => !!item)
+      .sort((a, b) => b.commentCount - a.commentCount || this.severityRank(b.severity) - this.severityRank(a.severity));
+  }
+
+  private toInsight(
+    rule: PainRule,
+    comments: Array<{ text: string; evidenceItemId: string; matches: string[] }>,
+    now: string,
+  ): IpCommentPainInsight | null {
+    if (!comments.length) return null;
+    const keywordFrequency = this.keywordFrequency(comments.flatMap((item) => item.matches));
+    const representativeComments = this.unique(comments.map((item) => item.text)).slice(0, 5);
+    const evidenceItemIds = this.unique(comments.map((item) => item.evidenceItemId));
+    const severity = this.severity(comments.length, rule.category);
+    const topKeyword = keywordFrequency[0]?.keyword || rule.label;
+    return {
+      id: this.stablePainId(rule.category, evidenceItemIds),
+      title: `${rule.label}: ${topKeyword}`,
+      category: rule.category,
+      severity,
+      summary: `${comments.length} 条评论集中体现了「${rule.label}」,高频线索是「${topKeyword}」。`,
+      commentCount: comments.length,
+      keywordFrequency,
+      representativeComments,
+      evidenceItemIds,
+      suggestedTopics: rule.suggestedTopics,
+      scriptAngles: rule.scriptAngles,
+      riskBoundaries: rule.riskBoundaries,
+      labelSystemId: '',
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private extractComments(rawText: string): string[] {
+    return this.unique(String(rawText || '')
+      .split(/[\r\n。!?!?;;]+/)
+      .map((item) => item.replace(/\s+/g, ' ').trim())
+      .filter((item) => item.length >= 4 && item.length <= 180));
+  }
+
+  private matchKeywords(text: string, keywords: string[]): string[] {
+    const lower = text.toLowerCase();
+    return keywords.filter((keyword) => lower.includes(keyword.toLowerCase()));
+  }
+
+  private keywordFrequency(keywords: string[]): Array<{ keyword: string; count: number }> {
+    const counts = new Map<string, number>();
+    for (const keyword of keywords) {
+      counts.set(keyword, (counts.get(keyword) || 0) + 1);
+    }
+    return [...counts.entries()]
+      .map(([keyword, count]) => ({ keyword, count }))
+      .sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword))
+      .slice(0, 12);
+  }
+
+  private severity(count: number, category: IpPainCategory): IpPainSeverity {
+    if (count >= 5 || category === 'purchase_intent' || category === 'risk') return 'high';
+    if (count >= 2) return 'medium';
+    return 'low';
+  }
+
+  private severityRank(severity: IpPainSeverity): number {
+    return severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
+  }
+
+  private unique(values: string[]): string[] {
+    return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))];
+  }
+
+  private stablePainId(category: IpPainCategory, evidenceItemIds: string[]): string {
+    const source = [category, ...[...evidenceItemIds].sort()].join('|');
+    return `ip_pain_${category}_${this.hash(source)}`;
+  }
+
+  private hash(value: string): string {
+    let hash = 5381;
+    for (let index = 0; index < value.length; index += 1) {
+      hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
+    }
+    return (hash >>> 0).toString(36);
+  }
+
+}

+ 43 - 0
src/app/services/ip-operator-evidence-adapter.spec.ts

@@ -0,0 +1,43 @@
+import { IpAccountSnapshot } from '../models/ip-operator.model';
+import { accountSnapshotToEvidenceItems, accountSourceEvidenceId } from './ip-operator-evidence-adapter';
+
+describe('accountSnapshotToEvidenceItems', () => {
+  it('creates profile, work and comment evidence from an owned account snapshot', () => {
+    const snapshot: IpAccountSnapshot = {
+      id: 'snapshot_1',
+      accountId: 'account_1',
+      dataMode: 'data_diagnosis',
+      capturedAt: '2026-06-15T00:00:00.000Z',
+      evidenceItemIds: [],
+      warnings: [],
+      profile: { nickname: 'FredTalk', signature: 'AI 工具实操', awemeCount: 1 },
+      works: [{
+        id: 'work_1',
+        accountId: 'account_1',
+        awemeId: 'aweme_1',
+        title: '新手如何安装 Skills',
+        desc: '很多人不知道先用哪个工具',
+        metrics: { likeCount: 10, commentCount: 2 },
+        interactionScore: 18,
+        isDeepSampled: true,
+        comments: [{
+          id: 'comment_1',
+          workId: 'work_1',
+          text: '收藏了但不知道怎么开始',
+          capturedAt: '2026-06-15T00:00:00.000Z',
+        }],
+        capturedAt: '2026-06-15T00:00:00.000Z',
+      }],
+    };
+
+    const items = accountSnapshotToEvidenceItems(snapshot, 'owned');
+
+    expect(items.some((item) => item.kind === 'owned_profile')).toBe(true);
+    expect(items.some((item) => item.kind === 'owned_post')).toBe(true);
+    expect(items.some((item) => item.kind === 'comment')).toBe(true);
+    expect(items.every((item) => item.platform === 'douyin')).toBe(true);
+    expect(items.every((item) => item.accountId === 'account_1')).toBe(true);
+    expect(items.find((item) => item.kind === 'comment')?.id)
+      .toBe(accountSourceEvidenceId('account_1', 'comment', 'comment_1'));
+  });
+});

+ 196 - 0
src/app/services/ip-operator-evidence-adapter.ts

@@ -0,0 +1,196 @@
+import { Observable, of } from 'rxjs';
+import { map } from 'rxjs/operators';
+import {
+  IpAccountSnapshot,
+  IpBenchmarkEvidence,
+  IpEvidenceItem,
+  IpEvidencePlatform,
+  IpMonitoredAccountRole,
+  IpOperatorPlan,
+} from '../models/ip-operator.model';
+import { IpOperatorEvidenceService, IpEvidenceFetchResult } from './ip-operator-evidence.service';
+import { IpOperatorService } from './ip-operator.service';
+
+export function accountSourceEvidenceId(
+  accountId: string,
+  sourceType: 'profile' | 'work' | 'comment',
+  sourceId: string,
+): string {
+  const safe = (value: string) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]+/g, '_');
+  return `ip_evidence_${safe(accountId)}_${sourceType}_${safe(sourceId)}`;
+}
+
+export function accountSnapshotToEvidenceItems(
+  snapshot: IpAccountSnapshot,
+  role: IpMonitoredAccountRole,
+): IpEvidenceItem[] {
+  const now = snapshot.capturedAt;
+  const owned = role === 'owned';
+  const ownerLabel = owned ? '自有账号' : '对标账号';
+  const profileKind: IpEvidenceItem['kind'] = owned ? 'owned_profile' : 'competitor_profile';
+  const workKind: IpEvidenceItem['kind'] = owned ? 'owned_post' : 'competitor_post';
+  const profileSourceId = `${snapshot.accountId}:profile`;
+  const profile: IpEvidenceItem = {
+    id: accountSourceEvidenceId(snapshot.accountId, 'profile', profileSourceId),
+    platform: 'douyin',
+    kind: profileKind,
+    bucket: 'accounts',
+    accountId: snapshot.accountId,
+    snapshotId: snapshot.id,
+    sourceRole: role,
+    title: `${ownerLabel}主页:${snapshot.profile.nickname || snapshot.accountId}`,
+    url: undefined,
+    authorName: snapshot.profile.nickname,
+    rawText: [
+      snapshot.profile.signature || '',
+      `粉丝 ${snapshot.profile.followerCount ?? '未知'}`,
+      `获赞 ${snapshot.profile.totalFavorited ?? '未知'}`,
+      `关注 ${snapshot.profile.followingCount ?? '未知'}`,
+      `作品 ${snapshot.profile.awemeCount ?? snapshot.works.length}`,
+    ].filter(Boolean).join('\n'),
+    summary: snapshot.profile.signature || '账号主页公开信息',
+    tags: [ownerLabel, '账号主页', `账号:${snapshot.accountId}`],
+    relatedTopicIds: [],
+    relatedScriptIds: [],
+    sourceId: profileSourceId,
+    evidenceRefs: [],
+    selectedForGeneration: owned,
+    confidence: 'high',
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  const works = snapshot.works.map((work): IpEvidenceItem => ({
+    id: accountSourceEvidenceId(snapshot.accountId, 'work', work.id),
+    platform: 'douyin',
+    kind: workKind,
+    bucket: 'accounts',
+    accountId: snapshot.accountId,
+    snapshotId: snapshot.id,
+    workId: work.id,
+    sourceRole: role,
+    title: `${ownerLabel}作品:${work.title || work.desc || work.awemeId}`,
+    url: work.url,
+    authorName: snapshot.profile.nickname,
+    rawText: [
+      work.desc || work.title,
+      work.structure?.hook ? `开头:${work.structure.hook}` : '',
+      work.structure?.topic ? `主题:${work.structure.topic}` : '',
+      work.structure?.style ? `风格:${work.structure.style}` : '',
+      work.structure?.cta ? `行动引导:${work.structure.cta}` : '',
+    ].filter(Boolean).join('\n'),
+    summary: `播放 ${work.metrics.playCount ?? 0},点赞 ${work.metrics.likeCount ?? 0},评论 ${work.metrics.commentCount ?? 0},收藏 ${work.metrics.collectCount ?? 0},分享 ${work.metrics.shareCount ?? 0}`,
+    tags: [ownerLabel, '账号作品', work.isDeepSampled ? '重点评论作品' : '最近作品', `账号:${snapshot.accountId}`],
+    relatedTopicIds: [],
+    relatedScriptIds: [],
+    sourceId: work.id,
+    evidenceRefs: work.url ? [work.url] : [],
+    selectedForGeneration: owned,
+    confidence: 'high',
+    createdAt: now,
+    updatedAt: now,
+  }));
+
+  const comments = snapshot.works.flatMap((work) => work.comments.map((comment): IpEvidenceItem => ({
+    id: accountSourceEvidenceId(snapshot.accountId, 'comment', comment.id),
+    platform: 'douyin',
+    kind: 'comment',
+    bucket: 'topics',
+    accountId: snapshot.accountId,
+    snapshotId: snapshot.id,
+    workId: work.id,
+    sourceRole: role,
+    title: `${ownerLabel}评论:${work.title || work.awemeId}`,
+    url: work.url,
+    authorName: comment.authorName,
+    rawText: comment.text,
+    summary: comment.text,
+    tags: [ownerLabel, '评论痛点', work.isDeepSampled ? '高互动作品评论' : '作品评论', `账号:${snapshot.accountId}`],
+    relatedTopicIds: [],
+    relatedScriptIds: [],
+    sourceId: comment.id,
+    evidenceRefs: work.url ? [work.url] : [],
+    selectedForGeneration: owned,
+    confidence: work.isDeepSampled ? 'high' : 'medium',
+    createdAt: comment.capturedAt || now,
+    updatedAt: now,
+  })));
+
+  return [profile, ...works, ...comments];
+}
+
+export interface IpOperatorEvidenceAdapterInput {
+  planId: string;
+  input: string;
+  benchmarkId?: string;
+  platform?: IpEvidencePlatform;
+  title?: string;
+  notes?: string;
+  tags?: string[];
+}
+
+export interface IpOperatorEvidenceAdapterResult {
+  plan: IpOperatorPlan;
+  evidenceItem?: IpEvidenceItem;
+  benchmarkEvidence?: IpBenchmarkEvidence;
+  rawResult?: IpEvidenceFetchResult;
+}
+
+export interface IpOperatorEvidenceAdapter {
+  readonly id: string;
+  readonly label: string;
+  canHandle(input: IpOperatorEvidenceAdapterInput): boolean;
+  fetch(input: IpOperatorEvidenceAdapterInput): Observable<IpOperatorEvidenceAdapterResult>;
+}
+
+export class DouyinEvidenceAdapter implements IpOperatorEvidenceAdapter {
+  readonly id = 'douyin';
+  readonly label = 'Douyin single-work evidence';
+
+  constructor(private readonly evidenceService: IpOperatorEvidenceService) {}
+
+  canHandle(input: IpOperatorEvidenceAdapterInput): boolean {
+    return !!this.evidenceService.extractAwemeId(input.input);
+  }
+
+  fetch(input: IpOperatorEvidenceAdapterInput): Observable<IpOperatorEvidenceAdapterResult> {
+    return this.evidenceService.fetchEvidenceFromDouyinUrl(input.planId, input.benchmarkId, input.input).pipe(
+      map((result) => ({
+        plan: result.plan,
+        benchmarkEvidence: result.evidence,
+        evidenceItem: result.plan.evidenceItems?.find((item) => item.id === result.job.evidenceId),
+        rawResult: result,
+      })),
+    );
+  }
+}
+
+export class ManualEvidenceAdapter implements IpOperatorEvidenceAdapter {
+  readonly id = 'manual';
+  readonly label = 'Manual pasted evidence';
+
+  constructor(private readonly ipOperator: IpOperatorService) {}
+
+  canHandle(input: IpOperatorEvidenceAdapterInput): boolean {
+    return !!String(input.input || '').trim();
+  }
+
+  fetch(input: IpOperatorEvidenceAdapterInput): Observable<IpOperatorEvidenceAdapterResult> {
+    const evidenceItem = this.ipOperator.addEvidenceItem(input.planId, {
+      platform: input.platform || 'manual',
+      kind: 'manual_note',
+      title: input.title?.trim() || String(input.input).trim().split(/\r?\n/)[0] || 'Manual evidence',
+      rawText: String(input.input || '').trim(),
+      summary: input.notes?.trim() || String(input.input || '').trim().slice(0, 160),
+      tags: input.tags || ['manual_adapter'],
+      relatedBenchmarkId: input.benchmarkId,
+      relatedTopicIds: [],
+      relatedScriptIds: [],
+      sourceId: undefined,
+      evidenceRefs: [],
+      confidence: 'medium',
+    });
+    const plan = this.ipOperator.getPlan(input.planId);
+    return of({ plan: plan!, evidenceItem });
+  }
+}

+ 74 - 0
src/app/services/ip-operator-evidence.service.spec.ts

@@ -1,7 +1,10 @@
 import { firstValueFrom, of } from 'rxjs';
 import { ViralAnalysis } from '../models/douyin-insight.model';
 import { IpOperatorPlan, IpOperatorProfile } from '../models/ip-operator.model';
+import { IpOperatorCommentPainService } from './ip-operator-comment-pain.service';
 import { IpOperatorEvidenceService } from './ip-operator-evidence.service';
+import { accountSnapshotToEvidenceItems } from './ip-operator-evidence-adapter';
+import { IpOperatorPublishPackageService } from './ip-operator-publish-package.service';
 import { IpOperatorService } from './ip-operator.service';
 
 describe('IpOperatorEvidenceService', () => {
@@ -39,6 +42,9 @@ describe('IpOperatorEvidenceService', () => {
     expect(result.evidence?.benchmarkId).toBe('benchmark-1');
     expect(result.evidence?.commentPainPoints.length).toBeGreaterThan(0);
     expect(saved?.benchmarkEvidence?.length).toBe(1);
+    expect(saved?.evidenceItems?.length).toBe(1);
+    expect(saved?.evidenceItems?.[0].kind).toBe('competitor_post');
+    expect(saved?.evidenceItems?.[0].selectedForGeneration).toBe(true);
     expect(saved?.evidenceFetchJobs?.[0].status).toBe('completed');
     expect(saved?.evidenceFetchJobs?.[0].analysisId).toBe('analysis-1');
   });
@@ -84,16 +90,79 @@ describe('IpOperatorEvidenceService', () => {
     expect(result.evidence?.source).toBe('viral_analysis');
     expect(result.evidence?.sourceId).toBe('analysis-saved');
     expect(saved?.benchmarkEvidence?.[0].benchmarkId).toBe('benchmark-1');
+    expect(saved?.evidenceItems?.[0].sourceId).toBe('7304943423281171749');
     expect(saved?.evidenceFetchJobs?.[0].analysisId).toBe('analysis-saved');
   });
+
+  it('upserts account evidence by stable source and filters it by account', () => {
+    const { evidence, ipOperator } = createEvidenceService();
+    const profile = ipOperator.saveProfile(profileFixture());
+    const plan = ipOperator.savePlan(basePlan(profile.id, 'plan-account-evidence'));
+    const snapshot = {
+      id: 'snapshot-account-1',
+      accountId: 'account-1',
+      profile: { nickname: 'FredTalk', signature: 'AI 工具实操' },
+      works: [{
+        id: 'work-account-1-aweme-1',
+        accountId: 'account-1',
+        awemeId: 'aweme-1',
+        title: 'Skills 入门',
+        desc: 'Skills 入门',
+        metrics: { likeCount: 10, commentCount: 1 },
+        interactionScore: 14,
+        isDeepSampled: true,
+        comments: [{
+          id: 'comment-1',
+          workId: 'work-account-1-aweme-1',
+          text: '不知道怎么开始',
+          capturedAt: '2026-06-15T00:00:00.000Z',
+        }],
+        capturedAt: '2026-06-15T00:00:00.000Z',
+      }],
+      dataMode: 'startup_hypothesis' as const,
+      evidenceItemIds: [],
+      capturedAt: '2026-06-15T00:00:00.000Z',
+      warnings: [],
+    };
+
+    const first = evidence.upsertEvidenceItems(plan.id, accountSnapshotToEvidenceItems(snapshot, 'owned'));
+    const second = evidence.upsertEvidenceItems(plan.id, accountSnapshotToEvidenceItems({
+      ...snapshot,
+      id: 'snapshot-account-2',
+      capturedAt: '2026-06-15T01:00:00.000Z',
+      profile: { ...snapshot.profile, signature: 'AI 工具实操与复盘' },
+    }, 'owned'));
+    const accountItems = evidence.accountEvidenceItems(plan.id, 'account-1');
+
+    expect(first.length).toBe(3);
+    expect(second.length).toBe(3);
+    expect(accountItems.length).toBe(3);
+    expect(accountItems.find((item) => item.kind === 'owned_profile')?.summary).toContain('复盘');
+  });
 });
 
 function createEvidenceService(viral: Partial<{ analyzeVideo: (...args: any[]) => any; getLocalAnalysis: (...args: any[]) => any }> = {}) {
+  const cloudStorage = {
+    upsert: vi.fn().mockResolvedValue(null),
+    delete: vi.fn().mockResolvedValue(null),
+    list: vi.fn().mockResolvedValue([]),
+    audit: vi.fn().mockResolvedValue(null),
+  };
+  const storageGovernance = {
+    writeJson: vi.fn().mockReturnValue({ stored: true, storage: 'localStorage', key: '', bytes: 0 }),
+    readJson: vi.fn().mockReturnValue(null),
+    remove: vi.fn(),
+  };
   const ipOperator = new IpOperatorService(
     { isLoggedIn: true, currentUser: { objectId: 'user-1' } } as any,
     {} as any,
     {} as any,
     {} as any,
+    new IpOperatorCommentPainService(),
+    new IpOperatorPublishPackageService(),
+    { save: (input: any) => ({ ...input, id: 'pub-1', score: 0, diagnosis: [], suggestions: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) } as any,
+    cloudStorage as any,
+    storageGovernance as any,
   );
   const viralAnalysis = {
     analyzeVideo: viral.analyzeVideo || (() => of(analysisFixture('analysis-default', 'medium'))),
@@ -187,6 +256,11 @@ function basePlan(profileId: string, id: string): IpOperatorPlan {
     sevenDayTestPlan: [],
     missingInputs: [],
     supplementInputs: [],
+    evidenceItems: [],
+    commentPainInsights: [],
+    commentLabelSystems: [],
+    contentCalendar: [],
+    publishPackages: [],
     createdAt: now,
     updatedAt: now,
   };

+ 82 - 2
src/app/services/ip-operator-evidence.service.ts

@@ -5,6 +5,7 @@ import { ViralAnalysis } from '../models/douyin-insight.model';
 import {
   IpBenchmarkEvidence,
   IpEvidenceFetchJob,
+  IpEvidenceItem,
   IpOperatorPlan,
 } from '../models/ip-operator.model';
 import { IpOperatorService } from './ip-operator.service';
@@ -23,6 +24,44 @@ export class IpOperatorEvidenceService {
     private readonly viralAnalysis: ViralAnalysisService,
   ) {}
 
+  upsertEvidenceItems(
+    planId: string,
+    incoming: IpEvidenceItem[],
+    options: { temporary?: boolean } = {},
+  ): IpEvidenceItem[] {
+    const plan = this.requirePlan(planId);
+    const existing = plan.evidenceItems || [];
+    const byId = new Map(existing.map((item) => [item.id, item]));
+    const bySource = new Map(existing.map((item) => [this.evidenceSourceKey(item), item]));
+    const saved: IpEvidenceItem[] = incoming.map((item) => {
+      const current = byId.get(item.id) || bySource.get(this.evidenceSourceKey(item));
+      return {
+        ...current,
+        ...item,
+        id: current?.id || item.id,
+        createdAt: current?.createdAt || item.createdAt,
+        updatedAt: item.updatedAt || new Date().toISOString(),
+      };
+    });
+    const replacedIds = new Set(saved.map((item) => item.id));
+    const replacedSources = new Set(saved.map((item) => this.evidenceSourceKey(item)));
+    this.ipOperator.savePlan({
+      ...plan,
+      evidenceItems: [
+        ...saved,
+        ...existing.filter((item) =>
+          !replacedIds.has(item.id) && !replacedSources.has(this.evidenceSourceKey(item))),
+      ].slice(0, 500),
+    }, options);
+    return saved;
+  }
+
+  accountEvidenceItems(planId: string, accountId: string): IpEvidenceItem[] {
+    return (this.requirePlan(planId).evidenceItems || [])
+      .filter((item) => item.accountId === accountId)
+      .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
+  }
+
   fetchEvidenceFromDouyinUrl(planId: string, benchmarkId: string | undefined, inputUrl: string): Observable<IpEvidenceFetchResult> {
     const plan = this.requirePlan(planId);
     const url = String(inputUrl || '').trim();
@@ -64,10 +103,11 @@ export class IpOperatorEvidenceService {
 
         const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, url, strength);
         const planWithEvidence = this.ipOperator.addBenchmarkEvidence(plan.id, evidence);
+        const evidenceItem = this.ipOperator.addEvidenceItem(planWithEvidence.id, this.benchmarkEvidenceToItem(evidence));
         const completed = this.finishJob(baseJob, {
           status: strength === 'weak' ? 'partial' : 'completed',
           evidenceStrength: strength,
-          evidenceId: evidence.id,
+          evidenceId: evidenceItem.id,
           analysisId: analysis.id,
           failureCode: strength === 'weak' ? 'comments_failed' : undefined,
           failureMessage: strength === 'weak' ? '仅获得弱证据,建议补充评论或逐字稿后再用于正式迁移判断。' : undefined,
@@ -95,6 +135,7 @@ export class IpOperatorEvidenceService {
     const strength = this.evidenceStrength(analysis);
     const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, '', strength);
     const updatedPlan = this.ipOperator.addBenchmarkEvidence(plan.id, evidence);
+    const evidenceItem = this.ipOperator.addEvidenceItem(updatedPlan.id, this.benchmarkEvidenceToItem(evidence));
     const now = new Date().toISOString();
     const job: IpEvidenceFetchJob = {
       id: this.createId('ip_evidence_job'),
@@ -104,7 +145,7 @@ export class IpOperatorEvidenceService {
       inputUrl: analysis.awemeId,
       awemeId: analysis.awemeId,
       status: strength === 'weak' ? 'partial' : 'completed',
-      evidenceId: evidence.id,
+      evidenceId: evidenceItem.id,
       analysisId: analysis.id,
       evidenceStrength: strength,
       createdAt: now,
@@ -169,6 +210,35 @@ export class IpOperatorEvidenceService {
     };
   }
 
+  private benchmarkEvidenceToItem(evidence: IpBenchmarkEvidence): Omit<IpEvidenceItem, 'id' | 'createdAt' | 'updatedAt'> {
+    const rawText = [
+      evidence.summary,
+      evidence.hookPattern,
+      evidence.reusableFrame,
+      ...evidence.commentPainPoints,
+      evidence.migrationSuggestion,
+      evidence.nonCopyableRisk,
+    ].filter(Boolean).join('\n');
+    return {
+      platform: 'douyin',
+      kind: 'competitor_post',
+      bucket: 'accounts',
+      title: evidence.title,
+      url: evidence.inputUrl,
+      authorName: evidence.authorName,
+      rawText,
+      summary: evidence.summary,
+      tags: ['douyin', 'benchmark', 'ip_operator'],
+      relatedBenchmarkId: evidence.benchmarkId,
+      relatedTopicIds: [],
+      relatedScriptIds: [],
+      sourceId: evidence.awemeId || evidence.sourceId,
+      evidenceRefs: evidence.evidenceRefs,
+      selectedForGeneration: true,
+      confidence: evidence.confidence,
+    };
+  }
+
   private evidenceStrength(analysis: ViralAnalysis): IpEvidenceFetchJob['evidenceStrength'] {
     const video = analysis.videoSnapshot;
     const hasDetail = !!(video.desc || video.authorName || video.diggCount || video.commentCount || video.shareCount || video.playCount);
@@ -208,4 +278,14 @@ export class IpOperatorEvidenceService {
   private createId(prefix: string): string {
     return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
   }
+
+  private evidenceSourceKey(item: IpEvidenceItem): string {
+    return [
+      item.accountId || '',
+      item.sourceRole || '',
+      item.platform,
+      item.kind,
+      item.sourceId || item.id,
+    ].join('|');
+  }
 }

+ 6 - 0
src/app/services/ip-operator-flags.ts

@@ -1 +1,7 @@
+// IP 操盘入口开关。临时发布保护时可设为 false,发布后继续开发时恢复 true。
 export const IP_OPERATOR_ENABLED = true;
+
+export function isIpOperatorTabEnabled(tab: string): boolean {
+  if (tab === 'ip-operator') return IP_OPERATOR_ENABLED;
+  return true;
+}

+ 297 - 11
src/app/services/ip-operator-prompts.ts

@@ -2,6 +2,8 @@ import {
   BenchmarkAccount,
   IpDiagnosis,
   IpBenchmarkEvidence,
+  IpCommentPainInsight,
+  IpEvidenceItem,
   IpIntakeSummary,
   IpOperatorPlan,
   IpOperatorProfile,
@@ -15,6 +17,186 @@ export const IP_OPERATOR_JSON_SYSTEM_PROMPT = [
   '输出要服务制作阶段的高质量方案雏形,不承诺直接发布即爆款。',
 ].join('\n');
 
+export const IP_ACCOUNT_STRATEGY_SYSTEM_PROMPT = [
+  '你是一个证据驱动的短视频 IP 运营策略师。',
+  '你要基于账号主页、近期作品、评论痛点、对标/爆款证据,生成账号 IP 定位提案、三类内容方向和可执行选题。',
+  '所有结论都必须是归纳后的判断,不能把评论原文、账号简介、作品标题直接当成最终方向或选题。',
+  '只输出严格 JSON,不输出 Markdown,不输出解释性前后缀。',
+].join('\n');
+
+export const IP_ACCOUNT_WORK_ANALYSIS_SYSTEM_PROMPT = [
+  '你是一个短视频单作品分析师,只分析当前给定的一个作品。',
+  '视频文字稿是主内容来源;评论、互动数据和账号上下文只能作为辅助证据。',
+  '你要输出作品结构分析和运营迁移判断,不能把每条评论分别写成报告。',
+  '只输出严格 JSON,不输出 Markdown,不输出解释性前后缀。',
+].join('\n');
+
+export function buildAccountWorkAnalysisPrompt(input: {
+  accountName: string;
+  accountIntentSummary: string;
+  work: {
+    workId: string;
+    awemeId: string;
+    title: string;
+    desc: string;
+    metricsSummary: string;
+    transcriptText: string;
+    comments: Array<{ id: string; text: string; likeCount?: number; replyCount?: number }>;
+  };
+}): string {
+  const work = input.work;
+  const compactInput = {
+    accountName: compactPromptText(input.accountName, 80),
+    accountIntentSummary: compactPromptText(input.accountIntentSummary, 260),
+    work: {
+      workId: work.workId,
+      awemeId: work.awemeId,
+      title: compactPromptText(work.title, 90),
+      desc: compactPromptText(work.desc, 220),
+      metricsSummary: compactPromptText(work.metricsSummary, 120),
+      transcriptText: compactPromptText(work.transcriptText, 2600),
+      comments: work.comments.slice(0, 12).map((comment) => ({
+        id: comment.id,
+        text: compactPromptText(comment.text, 120),
+        likeCount: comment.likeCount,
+        replyCount: comment.replyCount,
+      })),
+    },
+  };
+  return [
+    '请基于下面“单个作品”的账号上下文、视频文字稿、互动数据和评论样本,生成作品级策略报告。',
+    '',
+    '硬性规则:',
+    '1. 一次只分析当前 workId 对应的一个作品,禁止引用或推断其他作品。',
+    '2. 视频文字稿 transcriptText 是主内容来源,结构分析必须明确使用文字稿信息。',
+    '3. 评论只能作为辅助证据,用于判断用户问题、痛点、购买意向或反馈,不允许把评论逐条写成独立报告。',
+    '4. 输出 reports 最多 2 条:kind=structure 表示基于文字稿的作品结构分析;kind=operation 表示运营迁移判断。',
+    '5. supportingEvidenceIds 只能引用输入 comments[].id 或当前 workId,不要自造证据 ID。',
+    '6. 如果文字稿不足以判断,要在 gaps 写明,不要用标题、描述或评论替代文字稿。',
+    '7. 只输出 JSON,不输出 Markdown,不要包裹 ```json。',
+    '',
+    '输入 JSON:',
+    JSON.stringify(compactInput),
+    '',
+    '输出 JSON 字段契约:',
+    [
+      '必须输出一个 JSON 对象:{workId,reports}。',
+      'workId 必须等于输入 work.workId。',
+      'reports[] 最多 2 条。',
+      'reports[].kind 只能是 structure 或 operation。',
+      'reports[].headline:不超过 24 个中文字符。',
+      'reports[].summary:基于文字稿归纳作品在讲什么,不能直接复制长段文字稿。',
+      'reports[].evidenceSignals:2-4 条证据信号,优先来自文字稿,其次互动数据和评论。',
+      'reports[].strategyJudgment:该作品对账号定位、栏目或后续选题的运营判断。',
+      'reports[].supportingEvidenceIds:引用当前 workId 或评论 id。',
+      'reports[].confidence:high|medium|low。',
+      'reports[].gaps:仍缺失的判断材料。',
+    ].join('\n'),
+  ].join('\n');
+}
+
+export function buildAccountStrategyPrompt(context: unknown, responseSchema: unknown): string {
+  const compactContext = compactAccountStrategyContext(context);
+  return [
+    '请根据下面的账号证据生成“证据驱动的半自动 IP 操盘诊断”。',
+    '',
+    '硬性规则:',
+    '1. 不能把评论原文、账号简介、作品标题直接当成方向或选题。',
+    '2. 方向必须是归纳后的系列主题,能指导未来 2-4 周内容生产。',
+    '3. 固定输出 3 个方向,role 必须分别是 growth、trust、interaction。',
+    '4. 每个方向都要说明适配当前 IP 的原因、来源证据、可借爆款结构和避免照搬风险。',
+    '5. 每个方向给 2-3 个高质量选题,每个选题必须包含目标人群、用户问题、证据、可借爆款结构、避免照搬点。',
+    '6. 爆款分析只借结构、用户问题和表达策略,不复制原标题、口播、人设和案例。',
+    '7. evidenceRefs 必须使用账号证据上下文 evidenceRefs 中已有的 id/sourceId,不能自造 ev_1 之类的新 ID。',
+    '8. evidenceRefs.quote 要写归纳后的证据信号,evidenceRefs.reason 要写它支持的运营判断;不能直接复制作品标题、简介、评论原文或账号简介。',
+    '9. 如果上下文包含 calibrationSummary、forbiddenTopics、forbiddenExpressions、rejectedEvidenceIds,必须把它们当成用户校准硬约束;禁区不得进入方向、选题和任务。',
+    '10. 如果上下文包含 retrospectiveSummary,必须把发布复盘结论用于判断下一轮方向,不得把已验证失败的动作重复当作强建议。',
+    '11. 被 rejectedEvidenceIds 命中的证据不能作为强依据;如原始数据与用户校准冲突,应输出“待验证假设”,不要伪装成确定结论。',
+    '12. 输出必须包含 appliedCalibrationSummary、confidenceLevel、evidenceGaps,用人能读懂的话说明本轮采用了哪些校准、当前置信度和仍缺什么。',
+    '13. 账号级诊断不得接收或复述完整视频文字稿;作品内容只能来自 workReports 中的作品级报告摘要。',
+    '14. 如果 workReports 为空,不要伪装成已完成作品级分析,必须在 evidenceGaps 写明“缺少基于视频文字稿的作品级报告”。',
+    '15. 每个 workReports 项代表一个作品/视频级分析,不是评论级分析;只能引用 workReports[].id、evidenceRefs 中已有 id 或真实评论证据 id。',
+    '16. 评论 evidence id 只能作为辅助支撑,不能把每条评论写成独立运营判断。',
+    '17. directions[].sourceEvidenceIds、topics[].evidenceRefs 和 taskSuggestions[].relatedEvidenceIds 应优先引用已存在的 workReports[].id;没有 workReports 时可以引用其他真实 evidenceRefs,但必须降低置信度并说明缺口。',
+    '18. 只输出 JSON,不输出 Markdown,不要包裹 ```json。',
+    '',
+    '账号证据上下文:',
+    JSON.stringify(compactContext),
+    '',
+    '输出 JSON 字段契约:',
+    compactAccountStrategySchema(responseSchema),
+  ].join('\n');
+}
+
+function compactAccountStrategyContext(context: unknown): unknown {
+  const value = context as Record<string, any>;
+  if (!value || typeof value !== 'object') return context;
+  return {
+    accountId: value['accountId'],
+    accountName: value['accountName'],
+    profileSummary: value['profileSummary'],
+    accountIntentSummary: value['accountIntentSummary'],
+    activePositioningSummary: value['activePositioningSummary'],
+    topWorks: (value['topWorks'] || []).slice(0, 5).map((item: unknown) => compactPromptText(item, 220)),
+    commentSignals: (value['commentSignals'] || []).slice(0, 16).map((item: unknown) => compactPromptText(item, 120)),
+    benchmarkSignals: (value['benchmarkSignals'] || []).slice(0, 6),
+    painSignals: (value['painSignals'] || []).slice(0, 6),
+    evidenceRefs: (value['evidenceRefs'] || []).slice(0, 40).map((item: Record<string, any>) => ({
+      id: item['id'],
+      sourceType: item['sourceType'],
+      sourceId: item['sourceId'],
+      label: compactPromptText(item['label'], 40),
+      quote: compactPromptText(item['quote'], 80),
+      reason: compactPromptText(item['reason'], 100),
+    })),
+    workReports: (value['workReports'] || []).slice(0, 8).map((report: Record<string, any>) => ({
+      id: report['id'],
+      workId: report['workId'],
+      title: compactPromptText(report['title'], 60),
+      sourceMode: report['sourceMode'],
+      sourceEvidenceId: report['sourceEvidenceId'],
+      supportingCommentEvidenceIds: (report['supportingCommentEvidenceIds'] || []).slice(0, 8),
+      reports: (report['reports'] || []).slice(0, 2).map((block: Record<string, any>) => ({
+        kind: block['kind'],
+        headline: compactPromptText(block['headline'], 40),
+        summary: compactPromptText(block['summary'], 160),
+        evidenceSignals: (block['evidenceSignals'] || []).slice(0, 4).map((item: unknown) => compactPromptText(item, 80)),
+        strategyJudgment: compactPromptText(block['strategyJudgment'], 160),
+        supportingEvidenceIds: (block['supportingEvidenceIds'] || []).slice(0, 8),
+        confidence: block['confidence'],
+        gaps: (block['gaps'] || []).slice(0, 4).map((item: unknown) => compactPromptText(item, 80)),
+      })),
+    })),
+    calibrationSummary: (value['calibrationSummary'] || []).slice(0, 6),
+    rejectedEvidenceIds: (value['rejectedEvidenceIds'] || []).slice(0, 12),
+    forbiddenTopics: (value['forbiddenTopics'] || []).slice(0, 12),
+    forbiddenExpressions: (value['forbiddenExpressions'] || []).slice(0, 12),
+    retrospectiveSummary: (value['retrospectiveSummary'] || []).slice(0, 6),
+    rawTextBudget: value['rawTextBudget'],
+  };
+}
+
+function compactPromptText(value: unknown, maxLength: number): string {
+  const text = String(value || '').replace(/\s+/g, ' ').trim();
+  if (!text) return '';
+  return text.length > maxLength ? `${text.slice(0, Math.max(1, maxLength - 1))}…` : text;
+}
+
+function compactAccountStrategySchema(responseSchema: unknown): string {
+  const schema = responseSchema as Record<string, unknown>;
+  const keys = schema && typeof schema === 'object' ? JSON.stringify(Object.keys(schema)) : '';
+  return [
+    `必须输出一个 JSON 对象,顶层字段:${keys || 'diagnosisSummary,positioningProposal,evidenceRefs,workReports,directions,taskSuggestions,appliedCalibrationSummary,confidenceLevel,evidenceGaps'}`,
+    'diagnosisSummary:accountSnapshot,currentIpGuess,actualAudience,positioningMismatch,strongestFollowReason,biggestGrowthBlocker,highInteractionPattern,commentPainSummary,sevenDayPriority。',
+    'positioningProposal:targetAudience,persona,followReason,contentPillars,expressionStyle,boundaries,suitableViralPatterns,assumptionsToValidate,observeMetrics。',
+    'evidenceRefs[]:id,sourceType,sourceId,label,quote,reason;id/sourceId 必须来自上下文。',
+    'workReports:账号上下文输入字段,账号级输出不需要重新生成 workReports;如果输出该字段,只能引用输入 workReports 的 id/workId。',
+    'directions[]:role(growth|trust|interaction),title,conclusion,targetAudience,purpose,sourceEvidenceIds,viralPatternRefs,commentPainRefs,cadenceSuggestion,observeMetrics,fitReason,risks,topics[]。',
+    'topics[]:title,targetAudience,userProblem,expectedImpact,evidenceRefs,viralPatternRefs,avoidCopyingRisk,fitReason。',
+    'taskSuggestions[]:title,reason,type,column,growthImpactScore,relatedEvidenceIds。',
+  ].join('\n');
+}
+
 export function buildIntakePrompt(profile: IpOperatorProfile, benchmarks: BenchmarkAccount[], supplementInputs: IpSupplementInput[] = []): string {
   return [
     '请归纳以下 IP 建档资料,输出 IpIntakeSummary JSON。',
@@ -47,20 +229,40 @@ export function buildOpportunityPrompt(plan: Partial<IpOperatorPlan>): string {
     '请把对标爆款点转成当前 IP 可长期使用的内容机会地图,输出 ContentOpportunity[] JSON 数组。',
     '输出 3-6 个机会方向。每项字段:id, type, fitScore, reason, audiencePainPoint, requiredMaterials, extendableTopics, riskBoundary。',
     '不要简单仿写对标账号,要解释爆款点如何迁移。',
+    evidencePromptContext(plan),
     payload(plan),
   ].join('\n\n');
 }
 
-export function buildTopicsScriptsPlanPrompt(plan: Partial<IpOperatorPlan>): string {
+export function buildTopicPlanPrompt(plan: Partial<IpOperatorPlan>): string {
   return [
-    '请生成选题、脚本和测试计划,输出 JSON 对象。',
-    '字段:topics, scripts, sevenDayTestPlan, thirtyDayDirection, missingInputs。',
-    'topics 必须 12 个,每项含 id,title,column,trafficLayer(broad|vertical|conversion),contentGoal,priority(high|medium|low),fitReason,source,requiredMaterials,scriptStatus(outline|full|needs_input),riskNote。',
-    'scripts 必须 12 个,其中 3 个 type=full 且有 fullScript,9 个 type=outline。每项含 id,topicId,type,title,hook,painPoint,viewpoint,caseOrMethod,goldenSentence,closingCta,fullScript,shootingTips,subtitleHighlights,replaceableSlots,riskNote。',
-    'sevenDayTestPlan 必须 7 天,每天说明测试什么假设。',
-    'thirtyDayDirection 给栏目结构、每周测试重点、素材方向和迭代路径。',
-    '即使存在 missingInputs,也必须基于现有定位、对标拆解和机会地图生成制作阶段草案;缺少真实案例、数据或授权时,把它写入 requiredMaterials、riskNote 或 scriptStatus=needs_input,不要返回空 topics/scripts/sevenDayTestPlan。',
-    '完整口播可以使用“素材位/案例位/数据位”占位,但必须包含清晰钩子、观点、支撑逻辑、结尾 CTA 和风险提示。',
+    '请只生成 12 个选题,输出合法 JSON 对象:{topics,missingInputs},不要 Markdown/注释/尾逗号。',
+    'topics 必须 12 个,字段:id,title,column,trafficLayer(broad|vertical|conversion),contentGoal,priority(high|medium|low),fitReason,source,requiredMaterials,scriptStatus(outline|full|needs_input),riskNote,scoreDimensions{heatSignal,accountFit,interactionPotential,executability,riskScore:0-100}。',
+    'missingInputs 不为空也要产出草案;缺案例/数据/授权写入 requiredMaterials、riskNote 或 scriptStatus=needs_input,不要返回空数组。',
+    '如有 EvidenceContext,source/fitReason 必须引用证据或痛点;只迁移问题和结构,不复制竞品话术。',
+    evidencePromptContext(plan),
+    payload(plan),
+  ].join('\n\n');
+}
+
+export function buildScriptPlanPrompt(plan: Partial<IpOperatorPlan>): string {
+  return [
+    '请只基于已给 topics 生成 12 个脚本,输出合法 JSON 对象:{scripts,missingInputs}。',
+    'scripts 必须 12 个:3 个 type=full(fullScript 180-260 字),9 个 type=outline。字段:id,topicId,type,title,hook,painPoint,viewpoint,caseOrMethod,goldenSentence,closingCta,fullScript,shootingTips,subtitleHighlights,replaceableSlots,riskNote。',
+    'fullScript 可用素材位/案例位/数据位,但要有钩子、观点、支撑、CTA 和风险提示。',
+    'topicId 必须来自 topics;如有 EvidenceContext,painPoint 优先引用证据或痛点。',
+    evidencePromptContext(plan),
+    payload(plan),
+  ].join('\n\n');
+}
+
+export function buildTestPlanPrompt(plan: Partial<IpOperatorPlan>): string {
+  return [
+    '请基于 topics/scripts 生成测试计划,输出合法 JSON 对象:{sevenDayTestPlan,thirtyDayDirection,missingInputs}。',
+    'sevenDayTestPlan 必须 7 天,字段:day,topicId,topicTitle,testGoal,contentType(broad|vertical|conversion),shootingFocus,observeMetrics,reviewQuestions。',
+    'thirtyDayDirection 字段:columnStructure,weeklyFocus,materialDirections,iterationPath。',
+    '只做半自动运营计划,不要自动发布、批量互动或绕过平台风控。',
+    evidencePromptContext(plan),
     payload(plan),
   ].join('\n\n');
 }
@@ -69,22 +271,106 @@ export function buildQualityPrompt(plan: Partial<IpOperatorPlan>): string {
   return [
     '请对完整 IP 起步方案做质量检查,输出 IpPlanQualityCheck JSON。',
     '字段:overallScore, positioningClarityScore, audienceClarityScore, evidenceSupportScore, benchmarkTransferScore, topicUsabilityScore, passed, blockingIssues, improvementSuggestions。',
+    '可在 diagnosis.accountScores 中参考 positioningClarity, contentStructure, interactionConversion, recognizability, sustainability;缺失时按中性分处理。',
     '通过规则:overallScore >= 70 且 blockingIssues 为空时 passed=true,否则 false。',
+    '质量判断要检查证据链:是否有可追溯证据、评论痛点是否支持选题、发布包是否仍需要人工审核。',
+    evidencePromptContext(plan),
     payload(plan),
   ].join('\n\n');
 }
 
-export function buildEvidenceBasedPolishPrompt(plan: Partial<IpOperatorPlan>, benchmarkEvidence: IpBenchmarkEvidence[]): string {
+export function buildEvidenceBasedPolishPrompt(
+  plan: Partial<IpOperatorPlan>,
+  benchmarkEvidence: IpBenchmarkEvidence[],
+  evidenceItems: IpEvidenceItem[] = [],
+  painInsights: IpCommentPainInsight[] = [],
+): string {
   return [
     '请基于对标证据生成“迁移判断”打磨建议,输出 IpEvidencePolishSuggestion[] JSON 数组。',
     '每项字段必须包含:targetType(opportunity|topic|script), targetId, title, rationale, suggestedChange, evidenceRefs, migrationSuggestion, nonCopyableRisk。',
     '建议必须服务机会地图、选题或脚本其中之一;不能停留在泛泛分析。',
     '必须说明可迁移的用户问题、内容结构或证据链,也必须说明不可照搬的风险。',
     '禁止直接生成同款脚本、同款标题、同款话术;只能输出 draft 建议,等待用户确认。',
-    payload({ plan, benchmarkEvidence }),
+    evidencePromptContext({ ...plan, evidenceItems, commentPainInsights: painInsights }),
+    payload({ plan, benchmarkEvidence, evidenceItems, painInsights }),
+  ].join('\n\n');
+}
+
+export function buildAccountDiagnosisPrompt(input: unknown): string {
+  return [
+    '你是证据驱动的抖音 IP 运营诊断师。',
+    '请基于账号主页、最近作品、重点评论、对标证据输出账号诊断、定位版本提案、3 个核心内容方向和优先任务。',
+    '输出必须是合法 JSON,不要 Markdown,不要用 ``` 包裹。',
+    '必须区分事实证据、推断和建议;样本不足时要明确标注起号假设模式。',
+    '任务优先级按涨粉、播放、互动影响判断,不按流程阻塞程度机械排序。',
+    payload(input),
+  ].join('\n\n');
+}
+
+export function buildViralAdaptationPrompt(input: unknown): string {
+  return [
+    '你是抖音爆款适配分析师,不是单条爆款摘要工具。',
+    '请先拆解爆款的开头钩子、内容铺垫、高潮/转折、结尾引导和评论触发点。',
+    '再结合当前账号定位版本判断:哪些可借、哪些不能照搬、需要改造成人设/场景/表达上的什么变化。',
+    '最后输出适合当前账号的内容方向和系列主题,不要直接给散点标题。',
+    '输出合法 JSON,不要 Markdown。',
+    payload(input),
+  ].join('\n\n');
+}
+
+export function buildShotScriptPrompt(input: unknown): string {
+  return [
+    '你是抖音分镜脚本策划师。',
+    '请基于当前账号定位版本、内容方向、选题、爆款适配结构和评论痛点,生成可拍摄的详细分镜脚本。',
+    '系统应自动建议视频时长,并说明为什么这个时长适合该选题。',
+    '输出合法 JSON:{recommendedDurationSeconds,durationReason,shots,optimizationSuggestions,riskNotes}。',
+    'shots 每段包含:startSecond,endSecond,segmentTitle,visualDesign,narration,subtitle,actionNotes,audioNotes。',
+    '脚本要具体到每段画面做什么、口播说什么、字幕强调什么、动作和音效怎么配合。',
+    '禁止照搬对标账号话术,只能迁移结构、用户问题和表达策略。',
+    payload(input),
   ].join('\n\n');
 }
 
+export function evidencePromptContext(plan: Partial<IpOperatorPlan>): string {
+  const evidenceItems = summarizeEvidenceItems(plan.evidenceItems || []);
+  const painInsights = summarizePainInsights(plan.commentPainInsights || []);
+  if (!evidenceItems.length && !painInsights.length) {
+    return 'EvidenceContext: 暂无结构化证据或评论痛点洞察。';
+  }
+  return `EvidenceContext JSON:${JSON.stringify({ evidenceItems, painInsights }, null, 2)}`;
+}
+
+function summarizeEvidenceItems(items: IpEvidenceItem[]): Array<Record<string, unknown>> {
+  return items.slice(0, 12).map((item) => ({
+    id: item.id,
+    platform: item.platform,
+    kind: item.kind,
+    bucket: item.bucket,
+    title: item.title,
+    summary: item.summary || item.rawText.slice(0, 160),
+    tags: item.tags,
+    confidence: item.confidence,
+    relatedTopicIds: item.relatedTopicIds,
+    selectedForGeneration: item.selectedForGeneration,
+    evidenceRefs: item.evidenceRefs,
+  }));
+}
+
+function summarizePainInsights(items: IpCommentPainInsight[]): Array<Record<string, unknown>> {
+  return items.slice(0, 10).map((item) => ({
+    id: item.id,
+    title: item.title,
+    category: item.category,
+    severity: item.severity,
+    summary: item.summary,
+    commentCount: item.commentCount,
+    representativeComments: item.representativeComments.slice(0, 3),
+    suggestedTopics: item.suggestedTopics,
+    scriptAngles: item.scriptAngles,
+    riskBoundaries: item.riskBoundaries,
+  }));
+}
+
 function payload(...parts: unknown[]): string {
   return `输入资料 JSON:${JSON.stringify(parts.length === 1 ? parts[0] : parts, null, 2)}`;
 }

+ 44 - 0
src/app/services/ip-operator-publish-package.service.spec.ts

@@ -0,0 +1,44 @@
+import { TestBed } from '@angular/core/testing';
+import { IpOperatorPublishPackageService } from './ip-operator-publish-package.service';
+import { IpAccountWorkSnapshot, IpPublishPackage } from '../models/ip-operator.model';
+
+describe('IpOperatorPublishPackageService', () => {
+  let service: IpOperatorPublishPackageService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [IpOperatorPublishPackageService],
+    });
+    service = TestBed.inject(IpOperatorPublishPackageService);
+  });
+
+  it('matches published work by title similarity and publish time', () => {
+    const match = service.matchPublishedWork({
+      publishPackage: {
+        id: 'pack_1',
+        topicId: 'topic_1',
+        titleOptions: ['新手第一次用 AI 工具应该先做哪三件事'],
+        scheduleSuggestion: '2026-06-16T10:00:00.000Z',
+      } as IpPublishPackage,
+      works: [
+        {
+          id: 'work_1',
+          accountId: 'account_1',
+          awemeId: 'aweme_1',
+          title: '新手第一次用AI工具,先做这三件事',
+          desc: '',
+          publishTime: '2026-06-16T10:20:00.000Z',
+          metrics: { likeCount: 20 },
+          interactionScore: 20,
+          isDeepSampled: false,
+          comments: [],
+          capturedAt: '2026-06-16T11:00:00.000Z',
+        } as IpAccountWorkSnapshot,
+      ],
+    });
+
+    expect(match?.awemeId).toBe('aweme_1');
+    expect(match?.status).toBe('auto_matched');
+    expect(match?.matchScore || 0).toBeGreaterThan(70);
+  });
+});

+ 300 - 0
src/app/services/ip-operator-publish-package.service.ts

@@ -0,0 +1,300 @@
+import { Injectable } from '@angular/core';
+import {
+  IpContentCalendarItem,
+  IpAccountWorkSnapshot,
+  IpEvidenceItem,
+  IpOperatorPlan,
+  IpPublishChecklistItem,
+  IpPublishPackage,
+  IpPublishPlatformVariants,
+  IpPublishWorkBinding,
+} from '../models/ip-operator.model';
+
+@Injectable({ providedIn: 'root' })
+export class IpOperatorPublishPackageService {
+  matchPublishedWork(input: { publishPackage: IpPublishPackage; works: IpAccountWorkSnapshot[] }): IpPublishWorkBinding | null {
+    const baseTitles = input.publishPackage.titleOptions || [];
+    let best: { work: IpAccountWorkSnapshot; score: number; reason: string } | null = null;
+
+    for (const work of input.works) {
+      const workText = work.title || work.desc || '';
+      const textScore = Math.max(...baseTitles.map((title) => this.simpleSimilarity(title, workText)), 0);
+      const timeScore = this.publishTimeScore(input.publishPackage.scheduleSuggestion, work.publishTime);
+      const score = Math.round(textScore * 0.75 + timeScore * 0.25);
+      if (!best || score > best.score) {
+        best = { work, score, reason: `标题相似度 ${textScore},发布时间接近度 ${timeScore}` };
+      }
+    }
+
+    if (!best || best.score < 60) return null;
+    const now = new Date().toISOString();
+    return {
+      id: `binding_${input.publishPackage.id}_${best.work.awemeId}`,
+      publishPackageId: input.publishPackage.id,
+      accountId: best.work.accountId,
+      awemeId: best.work.awemeId,
+      workUrl: best.work.url,
+      status: 'auto_matched',
+      matchScore: best.score,
+      matchReason: best.reason,
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  build(plan: IpOperatorPlan, calendarItem: IpContentCalendarItem, now = new Date().toISOString()): IpPublishPackage {
+    const topic = plan.topics.find((item) => item.id === calendarItem.topicId);
+    const script = calendarItem.scriptId
+      ? plan.scripts.find((item) => item.id === calendarItem.scriptId)
+      : plan.scripts.find((item) => item.topicId === calendarItem.topicId && item.type === 'full')
+        || plan.scripts.find((item) => item.topicId === calendarItem.topicId);
+    const evidence = this.relatedEvidence(plan, calendarItem);
+    const painInsights = (plan.commentPainInsights || []).filter((item) =>
+      item.evidenceItemIds.some((id) => evidence.some((evidenceItem) => evidenceItem.id === id)),
+    );
+    const evidenceIds = evidence.map((item) => item.id);
+    const painInsightIds = painInsights.map((item) => item.id);
+    const matrixAccount = calendarItem.matrixAccountId
+      ? (plan.matrixAccounts || []).find((item) => item.id === calendarItem.matrixAccountId)
+      : undefined;
+    const title = calendarItem.title || topic?.title || '未命名内容';
+    const scriptText = script?.fullScript || [
+      script?.hook,
+      script?.painPoint,
+      script?.viewpoint,
+      script?.caseOrMethod,
+      script?.goldenSentence,
+      script?.closingCta,
+    ].filter(Boolean).join('\n\n');
+
+    return {
+      id: this.createId('ip_publish_package'),
+      calendarItemId: calendarItem.id,
+      topicId: calendarItem.topicId,
+      scriptId: script?.id || calendarItem.scriptId,
+      platform: calendarItem.platform,
+      accountName: calendarItem.accountName || matrixAccount?.accountName || '',
+      titleOptions: this.unique([
+        title,
+        script?.hook ? `${script.hook} ${title}` : '',
+        painInsights[0]?.suggestedTopics[0] || '',
+      ]).slice(0, 5),
+      coverSuggestions: this.coverSuggestions(calendarItem.platform, topic?.column || calendarItem.contentType),
+      caption: this.caption(title, script, evidence, painInsights, matrixAccount?.role),
+      scriptText,
+      videoGoal: this.unique([calendarItem.goal, topic?.contentGoal || '', topic?.expectedRole ? this.roleGoal(topic.expectedRole) : '']),
+      durationAndRhythm: script?.recommendedDurationSeconds
+        ? `${script.recommendedDurationSeconds} 秒;${script.durationReason || '按分镜节奏拍摄'}`
+        : '按 30-60 秒短视频节奏准备,前三秒直接给判断。',
+      shootingChecklist: this.shootingChecklist(script, topic?.requiredMaterials || []),
+      subtitleHighlights: script?.subtitleHighlights || script?.shotList?.map((shot) => shot.subtitle).filter(Boolean).slice(0, 6) || [],
+      audioSuggestion: this.audioSuggestion(calendarItem.platform, topic?.expectedRole),
+      hashtags: this.hashtags(calendarItem.platform, topic?.column, evidence),
+      commentGuidance: script?.closingCta || painInsights[0]?.suggestedTopics[0] || '引导用户在评论区留下当前最卡的问题,人工筛选后再回复。',
+      pinnedCommentSuggestion: this.pinnedComment(title, painInsights[0]?.representativeComments[0]),
+      postPublishMonitoringTargets: this.monitoringTargets(topic?.expectedRole),
+      materialFiles: (topic?.requiredMaterials || []).map((name) => ({
+        id: this.createId('ip_material_file'),
+        name,
+        status: calendarItem.materialStatus,
+      })),
+      scheduleSuggestion: calendarItem.date,
+      productLink: '',
+      productTitle: '',
+      isOriginal: true,
+      isDraft: true,
+      materialChecklist: this.materialChecklist(topic?.requiredMaterials || []),
+      riskChecklist: this.riskChecklist(script?.riskNote, painInsights.flatMap((item) => item.riskBoundaries)),
+      platformVariants: this.platformVariants(title, scriptText, script, evidence, painInsights, calendarItem.platform, matrixAccount?.role),
+      evidenceItemIds: evidenceIds,
+      painInsightIds,
+      status: 'draft',
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private relatedEvidence(plan: IpOperatorPlan, calendarItem: IpContentCalendarItem): IpEvidenceItem[] {
+    const ids = new Set(calendarItem.evidenceItemIds || []);
+    for (const item of plan.evidenceItems || []) {
+      if (item.relatedTopicIds.includes(calendarItem.topicId)) ids.add(item.id);
+      if (calendarItem.scriptId && item.relatedScriptIds.includes(calendarItem.scriptId)) ids.add(item.id);
+    }
+    return (plan.evidenceItems || []).filter((item) => ids.has(item.id)).slice(0, 8);
+  }
+
+  private caption(title: string, script: IpOperatorPlan['scripts'][number] | undefined, evidence: IpEvidenceItem[], painInsights: IpOperatorPlan['commentPainInsights'], accountRole = ''): string {
+    const insights = painInsights || [];
+    const pain = insights[0]?.representativeComments[0] || script?.painPoint || '';
+    const proof = evidence[0]?.summary || evidence[0]?.rawText || '';
+    return [
+      title,
+      pain ? `痛点:${pain}` : '',
+      script?.viewpoint ? `观点:${script.viewpoint}` : '',
+      proof ? `证据:${this.truncate(proof, 100)}` : '',
+      accountRole ? `账号角色:${accountRole}` : '',
+      script?.closingCta ? `行动引导:${script.closingCta}` : '',
+    ].filter(Boolean).join('\n');
+  }
+
+  private coverSuggestions(platform: IpContentCalendarItem['platform'], column: string): string[] {
+    const base = [
+      '封面主文案优先使用一个具体用户痛点。',
+      '保持一个视觉焦点,不照搬竞品版式。',
+    ];
+    if (platform === 'xiaohongshu') return [`${column || '栏目'}清单式封面`, ...base];
+    if (platform === 'douyin') return ['首帧直接说清冲突或问题。', ...base];
+    return base;
+  }
+
+  private hashtags(platform: IpContentCalendarItem['platform'], column: string | undefined, evidence: IpEvidenceItem[]): string[] {
+    return this.unique([
+      platform,
+      column || '',
+      ...evidence.flatMap((item) => item.tags || []),
+      'IP运营',
+      '内容选题',
+    ]).slice(0, 10);
+  }
+
+  private materialChecklist(requiredMaterials: string[]): IpPublishChecklistItem[] {
+    const items = requiredMaterials.length ? requiredMaterials : ['确认原始素材', '复核标题和封面'];
+    return items.map((label) => ({
+      id: this.createId('ip_publish_check'),
+      label,
+      checked: false,
+      required: true,
+    }));
+  }
+
+  private roleGoal(role: string): string {
+    if (role === 'growth') return '涨粉和扩大播放';
+    if (role === 'trust') return '建立信任和收藏';
+    if (role === 'interaction') return '提升评论和问题反馈';
+    return '验证内容方向';
+  }
+
+  private shootingChecklist(script: IpOperatorPlan['scripts'][number] | undefined, requiredMaterials: string[]): string[] {
+    const shotItems = script?.shotList?.map((shot) => `${shot.startSecond}-${shot.endSecond} 秒:${shot.visualDesign}`) || [];
+    return this.unique([
+      ...shotItems,
+      ...requiredMaterials.map((item) => `准备素材:${item}`),
+      '真人出镜开头必须人工确认口播是否自然。',
+      '发布前检查封面、字幕和风险提醒。',
+    ]).slice(0, 12);
+  }
+
+  private audioSuggestion(platform: IpContentCalendarItem['platform'], role?: string): string {
+    if (role === 'growth') return '节奏稍快、轻鼓点或干净口播底噪,避免压过人声。';
+    if (role === 'trust') return '低音量轻音乐或纯口播,突出案例和方法论可信度。';
+    if (role === 'interaction') return '保留口播停顿,结尾提问处降低背景音量。';
+    return platform === 'douyin' ? '以清晰人声为主,BGM 只做氛围辅助。' : '保持轻量背景音,优先保证信息可读。';
+  }
+
+  private pinnedComment(title: string, representativeComment = ''): string {
+    const question = representativeComment || `你做「${title}」时最卡在哪一步?`;
+    return `置顶评论建议:${question} 我会优先整理高频问题做下一条。`;
+  }
+
+  private monitoringTargets(role?: string): string[] {
+    return this.unique([
+      '发布后 2 小时记录播放、点赞、评论、收藏、分享。',
+      role === 'growth' ? '重点观察播放增长和新关注。' : '',
+      role === 'trust' ? '重点观察收藏、长评论和咨询意向。' : '',
+      role === 'interaction' ? '重点整理评论区新问题和负反馈。' : '',
+      '下一次账号刷新后绑定真实作品并生成复盘任务。',
+    ]);
+  }
+
+  private riskChecklist(scriptRisk: string | undefined, insightRisks: string[]): IpPublishChecklistItem[] {
+    return this.unique([
+      scriptRisk || '',
+      ...insightRisks,
+      '发布前必须人工复核。',
+      '不使用自动评论、Cookie 池、代理池或无人值守发布。',
+    ]).map((label) => ({
+      id: this.createId('ip_risk_check'),
+      label,
+      checked: false,
+      required: true,
+    }));
+  }
+
+  private platformVariants(
+    title: string,
+    scriptText: string,
+    script: IpOperatorPlan['scripts'][number] | undefined,
+    evidence: IpEvidenceItem[],
+    painInsights: NonNullable<IpOperatorPlan['commentPainInsights']>,
+    platform: IpContentCalendarItem['platform'],
+    accountRole = '',
+  ): IpPublishPlatformVariants {
+    const pain = painInsights[0]?.representativeComments[0] || script?.painPoint || title;
+    const proof = evidence[0]?.summary || evidence[0]?.title || '';
+    const hook = script?.hook || `为什么要做「${title}」?`;
+    const viewpoint = script?.viewpoint || proof || title;
+    const hashtags = this.hashtags(platform, script?.title, evidence);
+    return {
+      xiaohongshu: {
+        title: this.truncate(`${title}|${pain}`, 42),
+        coverText: this.truncate(pain, 24),
+        imageNoteOutline: this.unique([
+          `痛点:${pain}`,
+          `判断:${viewpoint}`,
+          proof ? `证据:${proof}` : '',
+          '清单:下一步怎么做',
+        ]),
+        hashtags,
+      },
+      douyin: {
+        firstThreeSecondsHook: this.truncate(hook, 60),
+        shotList: this.unique([
+          '真人出镜开头直接抛出冲突或问题。',
+          proof ? `插入证据说明:${this.truncate(proof, 50)}` : '插入一个真实素材截图或案例。',
+          accountRole ? `语气保持和账号角色一致:${accountRole}` : '结尾只保留一个人工确认的行动引导,不做自动评论诱导。',
+        ]),
+        spokenScript: scriptText || [hook, viewpoint, script?.closingCta].filter(Boolean).join('\n'),
+        subtitleEmphasis: this.unique([pain, viewpoint, script?.goldenSentence || '']).slice(0, 4),
+      },
+      wechat: {
+        calmerTitle: this.truncate(title.replace(/[!?!?]/g, ''), 36),
+        intro: `从用户问题切入:${pain}`,
+        body: scriptText || viewpoint,
+        endingCta: script?.closingCta || (accountRole ? `邀请读者从「${accountRole}」视角判断是否适用。` : '邀请读者判断这是否适合自己的当前阶段。'),
+      },
+    };
+  }
+
+  private unique(values: string[]): string[] {
+    return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))];
+  }
+
+  private truncate(value: string, length: number): string {
+    const text = String(value || '').trim();
+    return text.length > length ? `${text.slice(0, length)}...` : text;
+  }
+
+  private simpleSimilarity(left: string, right: string): number {
+    const normalize = (value: string) => String(value || '').replace(/\s+/g, '').toLowerCase();
+    const a = new Set(normalize(left).split(''));
+    const b = new Set(normalize(right).split(''));
+    if (!a.size && !b.size) return 0;
+    const intersection = [...a].filter((char) => b.has(char)).length;
+    const union = new Set([...a, ...b]).size || 1;
+    return Math.round((intersection / union) * 100);
+  }
+
+  private publishTimeScore(expected?: string, actual?: string): number {
+    if (!expected || !actual) return 50;
+    const diffHours = Math.abs(new Date(expected).getTime() - new Date(actual).getTime()) / 36e5;
+    if (!Number.isFinite(diffHours)) return 50;
+    if (diffHours <= 1) return 100;
+    if (diffHours <= 12) return 80;
+    if (diffHours <= 48) return 60;
+    return 20;
+  }
+
+  private createId(prefix: string): string {
+    return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+}

+ 135 - 0
src/app/services/ip-publish-package-refiner.service.spec.ts

@@ -0,0 +1,135 @@
+import { TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import {
+  IpContentDirection,
+  IpPositioningVersion,
+  IpPublishPackage,
+  IpScript,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LlmService } from './llm.service';
+import { IpPublishPackageRefinerService } from './ip-publish-package-refiner.service';
+
+describe('IpPublishPackageRefinerService', () => {
+  let service: IpPublishPackageRefinerService;
+  let askWithSystem: ReturnType<typeof vi.fn>;
+
+  beforeEach(() => {
+    askWithSystem = vi.fn();
+    TestBed.configureTestingModule({
+      providers: [
+        IpPublishPackageRefinerService,
+        { provide: LlmService, useValue: { askWithSystem } },
+      ],
+    });
+    service = TestBed.inject(IpPublishPackageRefinerService);
+  });
+
+  it('refines publish expression from confirmed positioning, topic and script', async () => {
+    askWithSystem.mockReturnValue(of(JSON.stringify({
+      summary: '让标题、封面和评论引导更贴合账号的职场 AI 定位。',
+      rationale: ['保留原选题,只增强用户收益表达。'],
+      titleOptions: ['别再囤 AI 工具,先用这 3 个解决工作问题'],
+      coverSuggestions: ['主文案:AI 工具别贪多'],
+      caption: '先按任务选工具,再谈效率。',
+      hashtags: ['#AI工具', '职场效率'],
+      commentGuidance: '你最想先解决哪个工作问题?',
+      pinnedCommentSuggestion: '留下具体场景,我继续拆解。',
+      subtitleHighlights: ['先按任务选工具'],
+      audioSuggestion: '纯口播,前 3 秒加轻提示音。',
+      postPublishMonitoringTargets: ['关注评论中的具体工作场景'],
+    })));
+
+    const result = await service.refine(contextFixture());
+
+    expect(result.sourceMode).toBe('llm');
+    expect(result.packagePatch.titleOptions).toEqual(['别再囤 AI 工具,先用这 3 个解决工作问题']);
+    expect(result.packagePatch.caption).toBe('先按任务选工具,再谈效率。');
+    expect(result.packagePatch.hashtags).toEqual(['AI工具', '职场效率']);
+    const [systemPrompt, userPrompt] = askWithSystem.mock.calls[0];
+    expect(systemPrompt).toContain('不是选题生成器');
+    expect(systemPrompt).toContain('不得改变选题方向');
+    expect(userPrompt).toContain('希望通过 AI 提升效率的职场人');
+    expect(userPrompt).toContain('完整口播稿正文');
+  });
+
+  it('keeps the rule draft intact when the LLM request fails', async () => {
+    askWithSystem.mockReturnValue(throwError(() => new Error('请求超时')));
+
+    const result = await service.refine(contextFixture());
+
+    expect(result.sourceMode).toBe('local_fallback');
+    expect(result.failureReason).toContain('请求超时');
+    expect(result.packagePatch.polishSource).toBe('local_fallback');
+    expect(result.packagePatch.titleOptions).toBeUndefined();
+    expect(result.packagePatch.caption).toBeUndefined();
+  });
+});
+
+function contextFixture() {
+  const topic = {
+    id: 'topic-1',
+    directionId: 'direction-1',
+    title: '打工人最该先用的 3 个 AI 工具',
+    targetAudience: '希望通过 AI 提升效率的职场人',
+    userProblem: '工具太多,不知道怎么选',
+    contentGoal: '建立可信的工具筛选标准',
+    requiredMaterials: [],
+    riskNote: '',
+  } as unknown as IpTopic;
+  const script = {
+    id: 'script-1',
+    topicId: topic.id,
+    title: topic.title,
+    recommendedDurationSeconds: 60,
+    durationReason: '需要完整讲清 3 个场景',
+    hook: '别再囤工具了。',
+    painPoint: topic.userProblem,
+    viewpoint: '先按任务选工具。',
+    closingCta: '留下你的工作场景。',
+    fullScript: '完整口播稿正文',
+    shotList: [],
+  } as unknown as IpScript;
+  const packageDraft = {
+    id: 'package-1',
+    calendarItemId: 'calendar-1',
+    topicId: topic.id,
+    scriptId: script.id,
+    platform: 'douyin',
+    accountName: '测试账号',
+    titleOptions: [topic.title],
+    coverSuggestions: ['规则封面'],
+    caption: '规则正文',
+    hashtags: ['AI工具'],
+    commentGuidance: '评论区交流',
+    pinnedCommentSuggestion: '留下问题',
+    subtitleHighlights: [],
+    audioSuggestion: '口播清晰',
+    postPublishMonitoringTargets: ['评论'],
+  } as unknown as IpPublishPackage;
+  return {
+    positioning: {
+      persona: '职场 AI 工具实测者',
+      targetAudience: '希望通过 AI 提升效率的职场人',
+      followReason: '获得经过筛选的实用方法',
+      contentPillars: ['工具实测'],
+      expressionStyle: ['清晰', '直接'],
+      boundaries: ['不夸大收益'],
+    } as unknown as IpPositioningVersion,
+    direction: {
+      id: 'direction-1',
+      role: 'trust',
+      title: 'AI 工具实测',
+      targetAudience: '职场人',
+      purpose: '建立信任',
+      observeMetrics: ['收藏', '关注'],
+      fitReason: '符合账号定位',
+      risks: [],
+    } as unknown as IpContentDirection,
+    topic,
+    script,
+    packageDraft,
+    strategyReport: null,
+    evidenceLabels: ['作品报告:用户在评论区追问工具选择'],
+  };
+}

+ 241 - 0
src/app/services/ip-publish-package-refiner.service.ts

@@ -0,0 +1,241 @@
+import { Injectable, inject } from '@angular/core';
+import { firstValueFrom, timeout } from 'rxjs';
+import {
+  IpAccountStrategyReport,
+  IpContentDirection,
+  IpPositioningVersion,
+  IpPublishPackage,
+  IpScript,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LlmService } from './llm.service';
+
+const PUBLISH_PACKAGE_LLM_TIMEOUT_MS = 90_000;
+const PUBLISH_PACKAGE_LLM_MAX_TOKENS = 4_000;
+
+export interface IpPublishPackageRefinementContext {
+  positioning?: IpPositioningVersion | null;
+  direction?: IpContentDirection | null;
+  topic: IpTopic;
+  script: IpScript;
+  packageDraft: IpPublishPackage;
+  strategyReport?: IpAccountStrategyReport | null;
+  evidenceLabels: string[];
+}
+
+export interface IpPublishPackageRefinementResult {
+  sourceMode: 'llm' | 'local_fallback';
+  packagePatch: Partial<IpPublishPackage>;
+  summary: string;
+  rationale: string[];
+  failureReason?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpPublishPackageRefinerService {
+  private readonly llm = inject(LlmService);
+
+  async refine(context: IpPublishPackageRefinementContext): Promise<IpPublishPackageRefinementResult> {
+    let raw = '';
+    try {
+      raw = await firstValueFrom(this.llm.askWithSystem(
+        this.systemPrompt(),
+        this.userPrompt(context),
+        { temperature: 0.25, max_tokens: PUBLISH_PACKAGE_LLM_MAX_TOKENS },
+      ).pipe(timeout(PUBLISH_PACKAGE_LLM_TIMEOUT_MS)));
+      return this.normalize(raw, context);
+    } catch (error) {
+      const failureReason = this.failureMessage(error);
+      return {
+        sourceMode: 'local_fallback',
+        failureReason,
+        summary: `AI 精修未完成,已保留当前发布包草稿:${failureReason}`,
+        rationale: ['保留已确认选题、脚本和人工发布边界,避免用失败结果覆盖可执行草稿。'],
+        packagePatch: {
+          polishSource: 'local_fallback',
+          polishSummary: `AI 精修未完成,已保留规则草稿:${failureReason}`,
+          polishRationale: ['LLM 请求或结构校验失败,发布包未做内容覆盖。'],
+          polishFailureReason: failureReason,
+        },
+      };
+    }
+  }
+
+  private systemPrompt(): string {
+    return [
+      '你是短视频 IP 运营的发布前精修编辑,不是选题生成器,也不是脚本重写器。',
+      '你的任务是在已确认的账号定位、内容方向、选题和分镜脚本基础上,精修人工发布包。',
+      '不得改变选题方向、目标用户、脚本核心观点和分镜顺序;不得新增自动发布、自动评论、自动私信、Cookie 池或代理池方案。',
+      '可以优化:标题候选、封面表达、发布文案、话题标签、评论区引导、置顶评论、画面重点字、声音建议和发布后复盘指标。',
+      '必须基于输入中的账号证据、作品报告、评论痛点和已确认脚本做判断,不能输出泛泛模板。',
+      '只返回合法 JSON 对象,不要 Markdown、代码围栏或解释。',
+    ].join('\n');
+  }
+
+  private userPrompt(context: IpPublishPackageRefinementContext): string {
+    const input = {
+      positioning: context.positioning ? {
+        persona: context.positioning.persona,
+        targetAudience: context.positioning.targetAudience,
+        followReason: context.positioning.followReason,
+        contentPillars: context.positioning.contentPillars,
+        expressionStyle: context.positioning.expressionStyle,
+        boundaries: context.positioning.boundaries,
+      } : null,
+      direction: context.direction ? {
+        role: context.direction.role,
+        title: context.direction.title,
+        targetAudience: context.direction.targetAudience,
+        purpose: context.direction.purpose,
+        observeMetrics: context.direction.observeMetrics,
+        fitReason: context.direction.fitReason,
+        risks: context.direction.risks,
+      } : null,
+      topic: {
+        title: context.topic.title,
+        targetAudience: context.topic.targetAudience,
+        userProblem: context.topic.userProblem,
+        contentGoal: context.topic.contentGoal,
+        fitReason: context.topic.fitReason,
+        requiredMaterials: context.topic.requiredMaterials,
+        riskNote: context.topic.riskNote,
+      },
+      script: {
+        title: context.script.title,
+        recommendedDurationSeconds: context.script.recommendedDurationSeconds,
+        durationReason: context.script.durationReason,
+        hook: context.script.hook,
+        painPoint: context.script.painPoint,
+        viewpoint: context.script.viewpoint,
+        closingCta: context.script.closingCta,
+        fullScript: this.truncate(context.script.fullScript || '', 2_400),
+        shotList: (context.script.shotList || []).map((shot) => ({
+          time: `${shot.startSecond}-${shot.endSecond}s`,
+          segmentTitle: shot.segmentTitle,
+          visualDesign: shot.visualDesign,
+          narration: shot.narration,
+          screenText: shot.subtitle,
+          actionNotes: shot.actionNotes,
+          audioNotes: shot.audioNotes,
+        })),
+      },
+      accountSignals: context.strategyReport ? {
+        currentIpGuess: context.strategyReport.diagnosisSummary.currentIpGuess,
+        actualAudience: context.strategyReport.diagnosisSummary.actualAudience,
+        strongestFollowReason: context.strategyReport.diagnosisSummary.strongestFollowReason,
+        biggestGrowthBlocker: context.strategyReport.diagnosisSummary.biggestGrowthBlocker,
+        highInteractionPattern: context.strategyReport.diagnosisSummary.highInteractionPattern,
+        commentPainSummary: context.strategyReport.diagnosisSummary.commentPainSummary,
+        sevenDayPriority: context.strategyReport.diagnosisSummary.sevenDayPriority,
+      } : null,
+      workReports: (context.strategyReport?.workReports || []).slice(0, 3).map((report) => ({
+        title: report.title,
+        reportBlocks: report.reports.slice(0, 4).map((block) => ({
+          headline: block.headline,
+          summary: block.summary,
+          strategyJudgment: block.strategyJudgment,
+          evidenceSignals: block.evidenceSignals,
+        })),
+      })),
+      evidenceLabels: context.evidenceLabels.slice(0, 12),
+      currentDraft: {
+        titleOptions: context.packageDraft.titleOptions,
+        coverSuggestions: context.packageDraft.coverSuggestions,
+        caption: context.packageDraft.caption,
+        hashtags: context.packageDraft.hashtags,
+        commentGuidance: context.packageDraft.commentGuidance,
+        pinnedCommentSuggestion: context.packageDraft.pinnedCommentSuggestion,
+        subtitleHighlights: context.packageDraft.subtitleHighlights,
+        audioSuggestion: context.packageDraft.audioSuggestion,
+        postPublishMonitoringTargets: context.packageDraft.postPublishMonitoringTargets,
+      },
+    };
+
+    return [
+      '请精修以下人工发布包。注意:只优化发布前表达与复盘提示,不要重写脚本主体,不要改变用户已选题目。',
+      JSON.stringify(input),
+      '返回 JSON 结构:',
+      JSON.stringify({
+        summary: '一句话说明这次精修解决了什么问题',
+        rationale: ['为什么这样精修,最多 4 条'],
+        titleOptions: ['3-5 个标题候选'],
+        coverSuggestions: ['2-4 条封面建议'],
+        caption: '发布正文,适合人工发布前复制并二次确认',
+        hashtags: ['3-8 个话题标签,不要带 #'],
+        commentGuidance: '评论区互动问题',
+        pinnedCommentSuggestion: '置顶评论建议',
+        subtitleHighlights: ['画面重点字,不是逐字字幕'],
+        audioSuggestion: '声音/BGM/节奏建议',
+        postPublishMonitoringTargets: ['发布后要看的指标或复盘问题'],
+      }),
+    ].join('\n');
+  }
+
+  private normalize(raw: string, context: IpPublishPackageRefinementContext): IpPublishPackageRefinementResult {
+    const parsed = JSON.parse(this.extractJson(raw)) as Record<string, unknown>;
+    const draft = context.packageDraft;
+    const summary = this.text(parsed['summary']) || 'AI 已基于账号定位、脚本和证据精修发布包。';
+    const rationale = this.stringList(parsed['rationale']).slice(0, 4);
+    const patch: Partial<IpPublishPackage> = {
+      polishSource: 'llm',
+      polishSummary: summary,
+      polishRationale: rationale,
+      polishFailureReason: '',
+      titleOptions: this.preferList(parsed['titleOptions'], draft.titleOptions, 5),
+      coverSuggestions: this.preferList(parsed['coverSuggestions'], draft.coverSuggestions, 4),
+      caption: this.text(parsed['caption']) || draft.caption,
+      hashtags: this.preferList(parsed['hashtags'], draft.hashtags, 8).map((item) => item.replace(/^#+/, '')),
+      commentGuidance: this.text(parsed['commentGuidance']) || draft.commentGuidance,
+      pinnedCommentSuggestion: this.text(parsed['pinnedCommentSuggestion']) || draft.pinnedCommentSuggestion,
+      subtitleHighlights: this.preferList(parsed['subtitleHighlights'], draft.subtitleHighlights || [], 8),
+      audioSuggestion: this.text(parsed['audioSuggestion']) || draft.audioSuggestion,
+      postPublishMonitoringTargets: this.preferList(parsed['postPublishMonitoringTargets'], draft.postPublishMonitoringTargets || [], 8),
+    };
+
+    if (!(patch.titleOptions || []).length || !patch.caption) {
+      throw new Error('发布包精修结果缺少标题或正文');
+    }
+
+    return {
+      sourceMode: 'llm',
+      summary,
+      rationale,
+      packagePatch: patch,
+    };
+  }
+
+  private preferList(value: unknown, fallback: string[], limit: number): string[] {
+    const list = this.unique(this.stringList(value)).slice(0, limit);
+    return list.length ? list : this.unique(fallback || []).slice(0, limit);
+  }
+
+  private extractJson(raw: string): string {
+    const text = String(raw || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+    const start = text.indexOf('{');
+    const end = text.lastIndexOf('}');
+    if (start < 0 || end <= start) throw new Error('LLM 响应中没有完整 JSON');
+    return text.slice(start, end + 1);
+  }
+
+  private stringList(value: unknown): string[] {
+    return Array.isArray(value) ? value.map((item) => this.text(item)).filter(Boolean) : [];
+  }
+
+  private unique(values: string[]): string[] {
+    return [...new Set(values.map((item) => this.text(item)).filter(Boolean))];
+  }
+
+  private text(value: unknown): string {
+    return typeof value === 'string' ? value.trim() : '';
+  }
+
+  private truncate(value: string, length: number): string {
+    const text = String(value || '').trim();
+    return text.length > length ? `${text.slice(0, length)}...` : text;
+  }
+
+  private failureMessage(error: unknown): string {
+    const message = error instanceof Error ? error.message : String(error || '未知错误');
+    return message.replace(/\s+/g, ' ').trim().slice(0, 180) || '未知错误';
+  }
+}

+ 289 - 0
src/app/services/ip-publish-retrospective-loop.service.spec.ts

@@ -0,0 +1,289 @@
+import { TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import {
+  IpAccountSnapshot,
+  IpAccountWorkSnapshot,
+  IpContentDirection,
+  IpMonitoredAccount,
+  IpOperatorPlan,
+  IpPositioningVersion,
+  IpPublishPackage,
+  IpPublishWorkBinding,
+  IpScript,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LlmService } from './llm.service';
+import { IpPublishRetrospectiveLoopService } from './ip-publish-retrospective-loop.service';
+
+describe('IpPublishRetrospectiveLoopService', () => {
+  let service: IpPublishRetrospectiveLoopService;
+  let askWithSystem: ReturnType<typeof vi.fn>;
+
+  beforeEach(() => {
+    askWithSystem = vi.fn();
+    TestBed.configureTestingModule({
+      providers: [
+        IpPublishRetrospectiveLoopService,
+        { provide: LlmService, useValue: { askWithSystem } },
+      ],
+    });
+    service = TestBed.inject(IpPublishRetrospectiveLoopService);
+  });
+
+  it('builds an LLM retrospective from package, binding, work metrics and comments', async () => {
+    askWithSystem.mockReturnValue(of(JSON.stringify(validRetrospectiveResponse())));
+
+    const result = await service.analyze(contextFixture());
+
+    expect(result.sourceMode).toBe('llm');
+    expect(result.conclusion).toContain('职场 AI 工具教学方向');
+    expect(result.causeSignals[0]).toMatchObject({ key: 'hook_strength', score: 72 });
+    expect(result.nextTopicSignals[0].source).toBe('answer_comment');
+    const prompt = askWithSystem.mock.calls[0][1] as string;
+    expect(prompt).toContain('评论有人追问具体工具名称');
+    expect(prompt).toContain('playCount');
+    expect(prompt).toContain('打工人最该先用的 3 个 AI 工具');
+  });
+
+  it('returns a low-confidence fallback when the LLM request fails', async () => {
+    askWithSystem.mockReturnValue(throwError(() => new Error('网关超时')));
+
+    const result = await service.analyze(contextFixture());
+
+    expect(result.sourceMode).toBe('local_fallback');
+    expect(result.confidence).toBe('low');
+    expect(result.failureReason).toContain('网关超时');
+    expect(result.nextTopicSignals.length).toBe(1);
+  });
+
+  it('falls back explicitly when LLM JSON misses required causes', async () => {
+    askWithSystem.mockReturnValue(of(JSON.stringify({
+      conclusion: '只有结论,没有归因。',
+      nextRoundBrief: '继续测试。',
+    })));
+
+    const result = await service.analyze(contextFixture());
+
+    expect(result.sourceMode).toBe('local_fallback');
+    expect(result.failureReason).toContain('复盘缺少表现归因');
+  });
+});
+
+function validRetrospectiveResponse() {
+  return {
+    confidence: 'medium',
+    conclusion: '这条内容证明职场 AI 工具教学方向有继续测试价值,但开头需要更快进入具体场景。',
+    causeSignals: [{
+      key: 'hook_strength',
+      label: '开头钩子',
+      score: 72,
+      judgment: '开头能指出选择困难,但没有马上给出具体收益。',
+      evidence: ['评论有人追问具体工具名称', '收藏高于点赞'],
+    }],
+    directionAdjustments: [{
+      directionTitle: '建立用户对 AI 工具的信任',
+      action: 'increase',
+      reason: '评论追问工具清单,说明用户愿意继续看实操内容。',
+    }],
+    nextTopicSignals: [{
+      title: '第一次用 Codex 最容易卡在哪一步',
+      source: 'answer_comment',
+      targetAudience: 'AI 工具新手',
+      userProblem: '不知道从哪里开始安装和配置',
+      reason: '评论持续追问具体操作。',
+    }],
+    scriptConstraints: [{
+      type: 'hook',
+      instruction: '前 3 秒直接说出用户当前卡住的问题和可得到的结果。',
+      avoid: '不要先讲工具概念。',
+    }],
+    positioningImpact: '继续强化“职场 AI 工具实操教练”。',
+    nextRoundBrief: '下一轮优先做更具体的上手路径和问题排查。',
+  };
+}
+
+function contextFixture() {
+  const account = {
+    id: 'account-1',
+    userId: 'user-1',
+    platform: 'douyin',
+    role: 'owned',
+    displayName: 'FredTalk',
+    uniqueId: '917997605',
+    enabled: true,
+    lastRefreshStatus: 'completed',
+    createdAt: now(),
+    updatedAt: now(),
+  } as IpMonitoredAccount;
+  const topic = {
+    id: 'topic-1',
+    directionId: 'direction-1',
+    title: '打工人最该先用的 3 个 AI 工具',
+    column: '信任建立方向',
+    trafficLayer: 'vertical',
+    contentGoal: '让新手知道先选哪类工具',
+    targetAudience: 'AI 工具新手',
+    userProblem: '工具太多,不知道怎么选',
+    priority: 'high',
+    fitReason: '历史教程内容收藏较高',
+    source: '账号诊断',
+    requiredMaterials: [],
+    scriptStatus: 'full',
+  } as IpTopic;
+  const direction = {
+    id: 'direction-1',
+    accountId: account.id,
+    positioningVersionId: 'positioning-1',
+    role: 'trust',
+    title: '建立用户对 AI 工具的信任',
+    targetAudience: 'AI 工具新手',
+    purpose: '用真实工具教程降低使用门槛',
+    sourceEvidenceIds: ['evidence-1'],
+    viralPatternRefs: [],
+    commentPainRefs: [],
+    topicIds: [topic.id],
+    cadenceSuggestion: '每周 2 条',
+    observeMetrics: ['收藏', '评论'],
+    fitReason: '用户愿意收藏具体教程',
+    risks: ['不要堆工具名'],
+    createdAt: now(),
+    updatedAt: now(),
+  } as IpContentDirection;
+  const script = {
+    id: 'script-1',
+    topicId: topic.id,
+    type: 'full',
+    title: topic.title,
+    hook: '别再囤工具了。',
+    painPoint: topic.userProblem,
+    viewpoint: '先按任务选工具。',
+    caseOrMethod: '用三个任务拆工具选择。',
+    goldenSentence: '工具不是越多越好。',
+    closingCta: '留下你的工作场景。',
+    recommendedDurationSeconds: 60,
+    shotList: [{
+      id: 'shot-1',
+      startSecond: 0,
+      endSecond: 3,
+      segmentTitle: '开头',
+      visualDesign: '正面口播',
+      narration: '别再囤工具了,先看你要解决什么任务。',
+      subtitle: '先按任务选工具',
+      actionNotes: '看镜头',
+      audioNotes: '轻提示音',
+    }],
+  } as IpScript;
+  const publishPackage = {
+    id: 'package-1',
+    calendarItemId: 'calendar-1',
+    topicId: topic.id,
+    scriptId: script.id,
+    platform: 'douyin',
+    accountName: account.displayName,
+    titleOptions: [topic.title],
+    coverSuggestions: ['AI 工具别贪多'],
+    caption: '先按任务选工具。',
+    scriptText: '完整口播稿',
+    videoGoal: ['验证信任方向'],
+    hashtags: ['AI工具'],
+    commentGuidance: '你最想解决哪个工作问题?',
+    postPublishMonitoringTargets: ['评论中的具体场景'],
+    materialFiles: [],
+    isOriginal: true,
+    isDraft: true,
+    materialChecklist: [],
+    riskChecklist: [],
+    evidenceItemIds: ['evidence-1'],
+    painInsightIds: [],
+    status: 'published',
+    createdAt: now(),
+    updatedAt: now(),
+  } as IpPublishPackage;
+  const publishedWork = {
+    id: 'work-1',
+    accountId: account.id,
+    awemeId: 'aweme-1',
+    title: topic.title,
+    desc: '这条视频讲 AI 工具选择。',
+    publishTime: now(),
+    metrics: {
+      playCount: 10000,
+      likeCount: 800,
+      commentCount: 120,
+      collectCount: 900,
+      shareCount: 100,
+    },
+    interactionScore: 2000,
+    isDeepSampled: true,
+    comments: [{
+      id: 'comment-1',
+      workId: 'work-1',
+      text: '评论有人追问具体工具名称',
+      likeCount: 12,
+      replyCount: 2,
+      capturedAt: now(),
+    }],
+    capturedAt: now(),
+  } as IpAccountWorkSnapshot;
+  const snapshot = {
+    id: 'snapshot-1',
+    accountId: account.id,
+    profile: {} as any,
+    works: [publishedWork],
+    dataMode: 'data_diagnosis',
+    evidenceItemIds: ['evidence-1'],
+    capturedAt: now(),
+    warnings: [],
+  } as IpAccountSnapshot;
+  const binding = {
+    id: 'binding-1',
+    publishPackageId: publishPackage.id,
+    accountId: account.id,
+    awemeId: publishedWork.awemeId,
+    status: 'auto_matched',
+    matchScore: 92,
+    matchReason: '标题和发布时间接近',
+    createdAt: now(),
+    updatedAt: now(),
+  } as IpPublishWorkBinding;
+  return {
+    plan: { id: 'plan-1', publishRetrospectives: [] } as unknown as IpOperatorPlan,
+    account,
+    snapshot,
+    publishPackage,
+    binding,
+    publishedWork,
+    previousWork: null,
+    positioning: {
+      id: 'positioning-1',
+      accountId: account.id,
+      version: 1,
+      status: 'active',
+      targetAudience: 'AI 工具新手',
+      persona: '职场 AI 工具实操教练',
+      followReason: '快速获得可执行工具教程',
+      contentPillars: ['工具教程'],
+      expressionStyle: ['直接'],
+      boundaries: ['不夸大收益'],
+      suitableViralPatterns: [],
+      evidenceItemIds: [],
+      assumptionsToValidate: [],
+      observeMetrics: ['收藏', '评论'],
+      createdAt: now(),
+      updatedAt: now(),
+    } as IpPositioningVersion,
+    direction,
+    topic,
+    script,
+    userReview: {
+      outcome: 'met_expectation' as const,
+      expectedGoal: ['验证信任方向'],
+      actualSignals: ['播放 10000', '评论 120', '收藏 900'],
+      notes: '',
+    },
+  };
+}
+
+function now(): string {
+  return '2026-07-07T00:00:00.000Z';
+}

+ 388 - 0
src/app/services/ip-publish-retrospective-loop.service.ts

@@ -0,0 +1,388 @@
+import { Injectable, inject } from '@angular/core';
+import { firstValueFrom, timeout } from 'rxjs';
+import {
+  IpAccountSnapshot,
+  IpAccountWorkSnapshot,
+  IpContentDirection,
+  IpMonitoredAccount,
+  IpOperatorPlan,
+  IpPositioningVersion,
+  IpPublishPackage,
+  IpPublishRetrospectiveInsight,
+  IpPublishWorkBinding,
+  IpRetrospectiveCauseKey,
+  IpRetrospectiveOutcome,
+  IpRetrospectiveScriptConstraint,
+  IpRetrospectiveTopicSignal,
+  IpScript,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { LlmService } from './llm.service';
+
+const RETROSPECTIVE_LLM_TIMEOUT_MS = 90_000;
+const RETROSPECTIVE_LLM_MAX_TOKENS = 4_000;
+
+export interface IpPublishRetrospectiveLoopContext {
+  plan: IpOperatorPlan;
+  account: IpMonitoredAccount;
+  snapshot: IpAccountSnapshot | null;
+  publishPackage: IpPublishPackage;
+  binding: IpPublishWorkBinding | null;
+  publishedWork: IpAccountWorkSnapshot | null;
+  previousWork: IpAccountWorkSnapshot | null;
+  positioning: IpPositioningVersion | null;
+  direction: IpContentDirection | null;
+  topic: IpTopic | null;
+  script: IpScript | null;
+  userReview: {
+    outcome: IpRetrospectiveOutcome;
+    expectedGoal: string[];
+    actualSignals: string[];
+    notes: string;
+  };
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpPublishRetrospectiveLoopService {
+  private readonly llm = inject(LlmService);
+
+  async analyze(context: IpPublishRetrospectiveLoopContext): Promise<IpPublishRetrospectiveInsight> {
+    try {
+      const raw = await firstValueFrom(this.llm.askWithSystem(
+        this.systemPrompt(),
+        this.userPrompt(context),
+        { temperature: 0.2, max_tokens: RETROSPECTIVE_LLM_MAX_TOKENS },
+      ).pipe(timeout(RETROSPECTIVE_LLM_TIMEOUT_MS)));
+      return this.normalizeInsight(raw, context);
+    } catch (error) {
+      return this.fallbackInsight(context, this.failureMessage(error));
+    }
+  }
+
+  private systemPrompt(): string {
+    return [
+      '你是短视频 IP 操盘复盘顾问。',
+      '你的任务不是写泛泛总结,而是基于发布包、真实作品指标、评论样本、账号定位、内容方向、选题和脚本,判断本次发布对下一轮运营有什么影响。',
+      '必须区分:选题适配、脚本执行、开头钩子、标题封面、发布时间、定位适配、评论反馈、数据不足。',
+      '不能直接复制评论原文作为结论;评论只能作为用户反馈证据。',
+      '不能因为单条作品表现好坏就彻底推翻账号定位,只能提出待验证的方向调整和下一轮约束。',
+      '不得建议自动发布、自动评论、自动私信、Cookie 池或代理池。',
+      '只返回合法 JSON 对象,不要 Markdown、代码围栏或解释。',
+    ].join('\n');
+  }
+
+  private userPrompt(context: IpPublishRetrospectiveLoopContext): string {
+    const work = context.publishedWork;
+    const previous = context.previousWork;
+    const input = {
+      account: {
+        name: context.account.displayName,
+        uniqueId: context.account.uniqueId,
+        accountId: context.account.accountId,
+        secUserId: context.account.secUserId,
+      },
+      positioning: context.positioning ? {
+        persona: context.positioning.persona,
+        targetAudience: context.positioning.targetAudience,
+        followReason: context.positioning.followReason,
+        contentPillars: context.positioning.contentPillars,
+        boundaries: context.positioning.boundaries,
+      } : null,
+      direction: context.direction ? {
+        id: context.direction.id,
+        title: context.direction.title,
+        role: context.direction.role,
+        targetAudience: context.direction.targetAudience,
+        purpose: context.direction.purpose,
+        fitReason: context.direction.fitReason,
+        risks: context.direction.risks,
+      } : null,
+      topic: context.topic ? {
+        id: context.topic.id,
+        title: context.topic.title,
+        targetAudience: context.topic.targetAudience,
+        userProblem: context.topic.userProblem,
+        contentGoal: context.topic.contentGoal,
+        fitReason: context.topic.fitReason,
+        riskNote: context.topic.riskNote,
+      } : null,
+      script: context.script ? {
+        id: context.script.id,
+        hook: context.script.hook,
+        viewpoint: context.script.viewpoint,
+        closingCta: context.script.closingCta,
+        recommendedDurationSeconds: context.script.recommendedDurationSeconds,
+        shotList: (context.script.shotList || []).slice(0, 8).map((shot) => ({
+          time: `${shot.startSecond}-${shot.endSecond}`,
+          segmentTitle: shot.segmentTitle,
+          narration: this.compact(shot.narration, 120),
+          visualDesign: this.compact(shot.visualDesign, 120),
+          subtitle: shot.subtitle,
+        })),
+      } : null,
+      publishPackage: {
+        id: context.publishPackage.id,
+        titleOptions: context.publishPackage.titleOptions,
+        caption: this.compact(context.publishPackage.caption, 300),
+        videoGoal: context.publishPackage.videoGoal,
+        commentGuidance: context.publishPackage.commentGuidance,
+        pinnedCommentSuggestion: context.publishPackage.pinnedCommentSuggestion,
+        postPublishMonitoringTargets: context.publishPackage.postPublishMonitoringTargets,
+        polishSource: context.publishPackage.polishSource,
+      },
+      binding: context.binding ? {
+        status: context.binding.status,
+        matchScore: context.binding.matchScore,
+        matchReason: context.binding.matchReason,
+        awemeId: context.binding.awemeId,
+      } : null,
+      publishedWork: work ? {
+        title: work.title,
+        desc: this.compact(work.desc, 260),
+        publishTime: work.publishTime,
+        metrics: work.metrics,
+        metricDelta: this.metricDelta(previous, work),
+        comments: (work.comments || [])
+          .slice()
+          .sort((a, b) => Number(b.likeCount || 0) - Number(a.likeCount || 0))
+          .slice(0, 12)
+          .map((comment) => ({
+            text: this.compact(comment.text, 100),
+            likeCount: comment.likeCount || 0,
+            replyCount: comment.replyCount || 0,
+          })),
+      } : null,
+      userReview: context.userReview,
+      recentRetrospectives: (context.plan.publishRetrospectives || []).slice(0, 5).map((item) => ({
+        outcome: item.outcome,
+        diagnosis: this.compact(item.diagnosis, 160),
+        nextRoundBrief: item.insight?.nextRoundBrief,
+      })),
+    };
+
+    return [
+      '请对这次真实发布做复盘,并给出下一轮运营约束。',
+      JSON.stringify(input),
+      '返回 JSON 结构:',
+      JSON.stringify({
+        confidence: 'low|medium|high',
+        conclusion: '一句明确复盘结论',
+        causeSignals: [{
+          key: 'topic_fit|script_execution|hook_strength|cover_title|publish_timing|positioning_fit|comment_signal|data_insufficient',
+          label: '中文标签',
+          score: 0,
+          judgment: '具体判断',
+          evidence: ['指标、评论或发布包证据'],
+        }],
+        directionAdjustments: [{
+          directionId: '可选',
+          directionTitle: '方向名称',
+          action: 'increase|keep|decrease|pause|test_more',
+          reason: '为什么',
+        }],
+        nextTopicSignals: [{
+          title: '下一轮候选选题',
+          source: 'continue_current|answer_comment|fix_script_gap|test_new_angle',
+          targetAudience: '目标人群',
+          userProblem: '用户问题',
+          reason: '来自本次复盘的原因',
+        }],
+        scriptConstraints: [{
+          type: 'hook|structure|cta|visual|tone|risk',
+          instruction: '下一轮脚本必须继承的约束',
+          avoid: '可选,下一轮避免什么',
+        }],
+        positioningImpact: '对当前账号定位的影响,只能是待验证判断',
+        nextRoundBrief: '下一轮运营简报',
+      }),
+    ].join('\n');
+  }
+
+  private normalizeInsight(raw: string, context: IpPublishRetrospectiveLoopContext): IpPublishRetrospectiveInsight {
+    const parsed = JSON.parse(this.extractJson(raw)) as Record<string, any>;
+    const causeSignals = this.array(parsed['causeSignals']).slice(0, 8).map((item) => ({
+      key: this.causeKey(item?.key),
+      label: this.text(item?.label) || this.causeLabel(this.causeKey(item?.key)),
+      score: this.score(item?.score),
+      judgment: this.requiredText(item?.judgment, '归因判断'),
+      evidence: this.textArray(item?.evidence).slice(0, 5),
+    }));
+    if (!causeSignals.length) throw new Error('复盘缺少表现归因');
+
+    const directionAdjustments = this.array(parsed['directionAdjustments']).slice(0, 5).map((item) => ({
+      directionId: this.text(item?.directionId) || context.direction?.id,
+      directionTitle: this.text(item?.directionTitle) || context.direction?.title || '当前内容方向',
+      action: this.directionAction(item?.action),
+      reason: this.requiredText(item?.reason, '方向调整原因'),
+    }));
+
+    const nextTopicSignals = this.array(parsed['nextTopicSignals']).slice(0, 5).map((item) => ({
+      title: this.requiredText(item?.title, '下一轮选题'),
+      source: this.topicSource(item?.source),
+      targetAudience: this.text(item?.targetAudience) || context.topic?.targetAudience || context.positioning?.targetAudience || '当前账号目标用户',
+      userProblem: this.text(item?.userProblem) || context.topic?.userProblem || '发布复盘中暴露的用户问题',
+      reason: this.requiredText(item?.reason, '选题原因'),
+    }));
+
+    const scriptConstraints = this.array(parsed['scriptConstraints']).slice(0, 8).map((item) => ({
+      type: this.scriptConstraintType(item?.type),
+      instruction: this.requiredText(item?.instruction, '脚本约束'),
+      avoid: this.text(item?.avoid) || undefined,
+    }));
+
+    return {
+      sourceMode: 'llm',
+      confidence: this.confidence(parsed['confidence']),
+      conclusion: this.requiredText(parsed['conclusion'], '复盘结论'),
+      causeSignals,
+      directionAdjustments,
+      nextTopicSignals,
+      scriptConstraints,
+      positioningImpact: this.text(parsed['positioningImpact']) || '本次发布只作为定位迭代的待验证信号。',
+      nextRoundBrief: this.requiredText(parsed['nextRoundBrief'], '下一轮简报'),
+    };
+  }
+
+  private fallbackInsight(context: IpPublishRetrospectiveLoopContext, failureReason: string): IpPublishRetrospectiveInsight {
+    const metrics = context.publishedWork?.metrics;
+    const hasComments = Number(metrics?.commentCount || 0) > 0 || !!context.publishedWork?.comments?.length;
+    const hasCollection = Number(metrics?.collectCount || 0) > 0 || Number(metrics?.shareCount || 0) > 0;
+    const conclusion = context.userReview.notes
+      || `本次只能做低置信度复盘:${context.userReview.outcome === 'below_expectation' ? '表现低于预期' : context.userReview.outcome === 'above_expectation' ? '表现高于预期' : '需要更多发布后信号验证'}。`;
+    const nextTopicTitle = hasComments
+      ? `回应评论区最常追问的${context.topic?.userProblem || '具体问题'}`
+      : `用更具体案例重做:${context.topic?.title || context.publishPackage.titleOptions[0] || '当前选题'}`;
+    return {
+      sourceMode: 'local_fallback',
+      confidence: 'low',
+      conclusion,
+      causeSignals: [{
+        key: hasComments ? 'comment_signal' : 'data_insufficient',
+        label: hasComments ? '评论信号' : '数据不足',
+        score: hasCollection ? 60 : 40,
+        judgment: hasComments
+          ? '已有评论反馈,可以进入下一轮问题收集,但仍需要 LLM 或人工复核具体归因。'
+          : '缺少足够评论或互动变化,暂不能稳定判断选题、脚本或定位是否成立。',
+        evidence: context.userReview.actualSignals,
+      }],
+      directionAdjustments: [{
+        directionId: context.direction?.id,
+        directionTitle: context.direction?.title || '当前内容方向',
+        action: context.userReview.outcome === 'below_expectation' ? 'test_more' : 'keep',
+        reason: '本地兜底只保留方向为待验证状态,不直接提高或暂停方向。',
+      }],
+      nextTopicSignals: [{
+        title: nextTopicTitle,
+        source: hasComments ? 'answer_comment' : 'test_new_angle',
+        targetAudience: context.topic?.targetAudience || context.positioning?.targetAudience || '当前账号目标用户',
+        userProblem: context.topic?.userProblem || '本次发布后仍未被充分验证的用户问题',
+        reason: '根据发布结果生成的低置信度下一轮候选,需要人工确认。',
+      }],
+      scriptConstraints: [{
+        type: 'hook',
+        instruction: '前 3 秒必须直接说明用户问题和可获得的具体结果。',
+        avoid: '不要先讲工具概念或背景铺垫。',
+      }],
+      positioningImpact: '本地兜底不改变定位,只作为下一轮待验证信号。',
+      nextRoundBrief: '先用更具体的问题、案例和开头钩子再测试一轮。',
+      failureReason,
+    };
+  }
+
+  private metricDelta(previous: IpAccountWorkSnapshot | null, current: IpAccountWorkSnapshot): Record<string, number> {
+    const prev = previous?.metrics || {};
+    const curr = current.metrics || {};
+    return {
+      playCount: Number(curr.playCount || 0) - Number(prev.playCount || 0),
+      likeCount: Number(curr.likeCount || 0) - Number(prev.likeCount || 0),
+      commentCount: Number(curr.commentCount || 0) - Number(prev.commentCount || 0),
+      collectCount: Number(curr.collectCount || 0) - Number(prev.collectCount || 0),
+      shareCount: Number(curr.shareCount || 0) - Number(prev.shareCount || 0),
+      followerGain: Number(curr.followerGain || 0) - Number(prev.followerGain || 0),
+    };
+  }
+
+  private extractJson(raw: string): string {
+    const text = String(raw || '').trim();
+    const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
+    const source = fenced?.[1]?.trim() || text;
+    const start = source.indexOf('{');
+    const end = source.lastIndexOf('}');
+    if (start < 0 || end <= start) throw new Error('LLM 未返回 JSON 对象');
+    return source.slice(start, end + 1);
+  }
+
+  private array(value: unknown): any[] {
+    return Array.isArray(value) ? value : [];
+  }
+
+  private text(value: unknown): string {
+    return String(value || '').trim();
+  }
+
+  private requiredText(value: unknown, label: string): string {
+    const text = this.text(value);
+    if (!text) throw new Error(`复盘缺少${label}`);
+    return this.compact(text, 500);
+  }
+
+  private textArray(value: unknown): string[] {
+    return this.array(value).map((item) => this.text(item)).filter(Boolean);
+  }
+
+  private compact(value: unknown, max: number): string {
+    const text = this.text(value).replace(/\s+/g, ' ');
+    return text.length > max ? `${text.slice(0, max)}...` : text;
+  }
+
+  private score(value: unknown): number {
+    const score = Number(value);
+    if (!Number.isFinite(score)) return 50;
+    return Math.max(0, Math.min(100, Math.round(score)));
+  }
+
+  private confidence(value: unknown): 'low' | 'medium' | 'high' {
+    return value === 'high' || value === 'medium' || value === 'low' ? value : 'medium';
+  }
+
+  private causeKey(value: unknown): IpRetrospectiveCauseKey {
+    const allowed: IpRetrospectiveCauseKey[] = ['topic_fit', 'script_execution', 'hook_strength', 'cover_title', 'publish_timing', 'positioning_fit', 'comment_signal', 'data_insufficient'];
+    return allowed.includes(value as IpRetrospectiveCauseKey) ? value as IpRetrospectiveCauseKey : 'data_insufficient';
+  }
+
+  private causeLabel(key: IpRetrospectiveCauseKey): string {
+    return {
+      topic_fit: '选题适配',
+      script_execution: '脚本执行',
+      hook_strength: '开头钩子',
+      cover_title: '标题封面',
+      publish_timing: '发布时间',
+      positioning_fit: '定位适配',
+      comment_signal: '评论信号',
+      data_insufficient: '数据不足',
+    }[key];
+  }
+
+  private directionAction(value: unknown): 'increase' | 'keep' | 'decrease' | 'pause' | 'test_more' {
+    return value === 'increase' || value === 'keep' || value === 'decrease' || value === 'pause' || value === 'test_more'
+      ? value
+      : 'test_more';
+  }
+
+  private topicSource(value: unknown): IpRetrospectiveTopicSignal['source'] {
+    return value === 'continue_current' || value === 'answer_comment' || value === 'fix_script_gap' || value === 'test_new_angle'
+      ? value
+      : 'test_new_angle';
+  }
+
+  private scriptConstraintType(value: unknown): IpRetrospectiveScriptConstraint['type'] {
+    return value === 'hook' || value === 'structure' || value === 'cta' || value === 'visual' || value === 'tone' || value === 'risk'
+      ? value
+      : 'structure';
+  }
+
+  private failureMessage(error: unknown): string {
+    if (error instanceof Error) return error.message;
+    return String(error || 'LLM 复盘失败');
+  }
+}

+ 73 - 0
src/app/services/ip-script-workbench.service.spec.ts

@@ -0,0 +1,73 @@
+import { IpScriptWorkbenchService } from './ip-script-workbench.service';
+
+describe('IpScriptWorkbenchService', () => {
+  let service: IpScriptWorkbenchService;
+
+  beforeEach(() => {
+    service = new IpScriptWorkbenchService();
+  });
+
+  it('recommends short duration for growth and strong opinion topics', () => {
+    const duration = service.recommendDurationSeconds({
+      directionRole: 'growth',
+      title: '老板 IP 为什么不能只拍产品',
+    });
+
+    expect(duration).toBe(45);
+  });
+
+  it('recommends medium duration for methodology topics', () => {
+    const duration = service.recommendDurationSeconds({
+      directionRole: 'trust',
+      title: '老板 IP 选题方法拆解',
+      contentGoal: '方法论',
+    });
+
+    expect(duration).toBe(60);
+  });
+
+  it('recommends longer duration for case topics', () => {
+    const duration = service.recommendDurationSeconds({
+      directionRole: 'trust',
+      title: '客户案例复盘:为什么这条视频涨粉',
+    });
+
+    expect(duration).toBe(75);
+  });
+
+  it('builds fallback shot-by-shot script', () => {
+    const script = service.buildFallbackScript({ title: '老板 IP 如何建立关注理由', directionRole: 'growth' });
+
+    expect(script.shots.length).toBeGreaterThanOrEqual(5);
+    expect(script.shots[0].startSecond).toBe(0);
+    expect(script.optimizationSuggestions.length).toBeGreaterThan(0);
+  });
+
+  it('builds detailed shot script with seconds, visual action, narration, subtitle and risk notes', () => {
+    const result = service.buildFallbackScript({
+      title: '新手小白第一次用 AI 工具应该先做哪三件事',
+      directionRole: 'growth',
+      contentGoal: '涨粉破圈',
+    });
+
+    expect(result.durationSeconds || result.recommendedDurationSeconds).toBeGreaterThanOrEqual(30);
+    expect(result.shots.length).toBeGreaterThanOrEqual(4);
+    expect(result.shots[0].startSecond).toBe(0);
+    expect(result.shots[0].visualDesign.length).toBeGreaterThan(0);
+    expect(result.shots[0].actionNotes.length).toBeGreaterThan(0);
+    expect(result.shots[0].narration.length).toBeGreaterThan(0);
+    expect((result.optimizationTips || result.optimizationSuggestions).length).toBeGreaterThan(0);
+    expect(result.riskNotes.length).toBeGreaterThan(0);
+  });
+
+  it('normalizes raw shot payloads', () => {
+    const shots = service.normalizeShots({
+      shots: [
+        { start: 0, end: 3, title: '开头', visual: '出镜', spoken: '先别急' },
+      ],
+    });
+
+    expect(shots[0].segmentTitle).toBe('开头');
+    expect(shots[0].narration).toBe('先别急');
+  });
+});

+ 150 - 0
src/app/services/ip-script-workbench.service.ts

@@ -0,0 +1,150 @@
+import { Injectable } from '@angular/core';
+import { IpContentDirectionRole, IpScriptShot } from '../models/ip-operator.model';
+
+export interface IpShotScriptResult {
+  durationSeconds?: number;
+  recommendedDurationSeconds: number;
+  durationReason: string;
+  shots: IpScriptShot[];
+  optimizationTips?: string[];
+  optimizationSuggestions: string[];
+  riskNotes: string[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpScriptWorkbenchService {
+  recommendDurationSeconds(input: { directionRole?: IpContentDirectionRole | string; title: string; contentGoal?: string }): number {
+    const text = `${input.title || ''} ${input.contentGoal || ''}`;
+    if (/案例|复盘|故事|经历|前后对比|客户/.test(text)) {
+      return 75;
+    }
+    if (/教程|步骤|流程|方法|拆解|知识|怎么|如何/.test(text)) {
+      return 60;
+    }
+    if (input.directionRole === 'growth' || /观点|反常识|避坑|为什么|别再|千万|破圈|涨粉/.test(text)) {
+      return 45;
+    }
+    return 60;
+  }
+
+  normalizeShots(raw: unknown): IpScriptShot[] {
+    const list = Array.isArray(raw)
+      ? raw
+      : Array.isArray((raw as any)?.shots)
+        ? (raw as any).shots
+        : [];
+    return list.map((item: any, index: number) => ({
+      id: String(item?.id || `shot_${index + 1}`),
+      startSecond: this.asNumber(item?.startSecond ?? item?.start ?? item?.start_second, index * 8),
+      endSecond: this.asNumber(item?.endSecond ?? item?.end ?? item?.end_second, index * 8 + 8),
+      segmentTitle: this.asText(item?.segmentTitle || item?.title || item?.name || `分镜 ${index + 1}`),
+      visualDesign: this.asText(item?.visualDesign || item?.visual || item?.scene || item?.['画面'] || ''),
+      narration: this.asText(item?.narration || item?.spoken || item?.script || item?.['口播'] || ''),
+      subtitle: this.asText(item?.subtitle || item?.caption || item?.['字幕'] || ''),
+      actionNotes: this.asText(item?.actionNotes || item?.action || item?.['动作'] || ''),
+      audioNotes: this.asText(item?.audioNotes || item?.audio || item?.music || item?.['音效'] || ''),
+    })).filter((shot: IpScriptShot) => shot.segmentTitle || shot.narration || shot.visualDesign);
+  }
+
+  buildFallbackScript(input: { title: string; directionRole?: IpContentDirectionRole | string; contentGoal?: string }): IpShotScriptResult {
+    const duration = this.recommendDurationSeconds(input);
+    const title = this.asText(input.title) || '待拍选题';
+    const shots = this.fallbackShots(title, duration);
+    const optimizationSuggestions = [
+      '前 3 秒先给明确判断或反常识结论,不要铺垫背景。',
+      '中段至少放入一个真实场景、案例位或对比位,避免空泛观点。',
+      '每 8-12 秒切一次画面或动作,避免纯口播造成流失。',
+      '结尾用评论问题承接下一条内容,优先引导真实用户问题。',
+    ];
+    return {
+      durationSeconds: duration,
+      recommendedDurationSeconds: duration,
+      durationReason: this.durationReason(duration),
+      shots,
+      optimizationTips: optimizationSuggestions,
+      optimizationSuggestions,
+      riskNotes: [
+        '当前为兜底分镜,需要结合账号定位版本、评论痛点和具体素材继续打磨。',
+        '不要照搬对标账号话术,只迁移结构和用户问题。',
+        '不要承诺确定收益或夸大工具效果。',
+        '涉及教程时需要展示真实操作画面或结果截图。',
+      ],
+    };
+  }
+
+  private fallbackShots(title: string, duration: number): IpScriptShot[] {
+    const mid = duration >= 75 ? 55 : duration >= 60 ? 42 : 32;
+    return [
+      {
+        id: 'shot_1',
+        startSecond: 0,
+        endSecond: 3,
+        segmentTitle: '黄金 3 秒钩子',
+        visualDesign: '主播正面出镜,第一帧放大核心判断字幕。',
+        narration: `如果你也在做「${title}」,先别急着照着别人拍。`,
+        subtitle: '别急着照搬爆款',
+        actionNotes: '语速快,眼神看镜头,第一句话直接给判断。',
+        audioNotes: '轻快鼓点起,字幕同步弹出。',
+      },
+      {
+        id: 'shot_2',
+        startSecond: 3,
+        endSecond: 12,
+        segmentTitle: '问题展开',
+        visualDesign: '切到账号主页或作品数据示意,标出用户最容易误判的点。',
+        narration: '真正影响播放和关注的,不是你拍了多少条,而是用户能不能一眼知道这个号跟自己有什么关系。',
+        subtitle: '用户为什么要关注你?',
+        actionNotes: '用手势指出屏幕上的 2 个关键词。',
+        audioNotes: '降低 BGM,突出重点词。',
+      },
+      {
+        id: 'shot_3',
+        startSecond: 12,
+        endSecond: mid,
+        segmentTitle: '核心观点',
+        visualDesign: '三段式字幕:人群、问题、关注理由。',
+        narration: '一条能跑起来的内容,通常同时说清三件事:我在帮谁、解决什么问题、为什么现在就该听我说。',
+        subtitle: '人群 / 问题 / 关注理由',
+        actionNotes: '每说一点切一次近景或字幕卡。',
+        audioNotes: '每个关键词加轻提示音。',
+      },
+      {
+        id: 'shot_4',
+        startSecond: mid,
+        endSecond: Math.max(mid + 8, duration - 8),
+        segmentTitle: '案例或方法位',
+        visualDesign: '展示一个可替换案例位:旧内容标题 vs 改造后标题。',
+        narration: '比如同样讲经验,泛泛说“做账号要坚持”很难被记住;改成“老板号三个月不涨粉,通常卡在这一步”,用户才知道和自己有关。',
+        subtitle: '把泛观点改成具体问题',
+        actionNotes: '左侧放旧标题,右侧放改造标题。',
+        audioNotes: '左右对比时加入切换音效。',
+      },
+      {
+        id: 'shot_5',
+        startSecond: Math.max(duration - 8, 36),
+        endSecond: duration,
+        segmentTitle: '互动收尾',
+        visualDesign: '回到主播出镜,底部出现评论问题。',
+        narration: '你现在做账号最卡的是定位、选题,还是脚本?评论区留一个词,我下一条直接拆。',
+        subtitle: '定位 / 选题 / 脚本,你卡在哪?',
+        actionNotes: '语气收紧,留 0.5 秒停顿给字幕。',
+        audioNotes: 'BGM 渐弱,保留最后一句清晰度。',
+      },
+    ];
+  }
+
+  private durationReason(duration: number): string {
+    if (duration <= 45) return '该选题适合强观点或涨粉破圈,控制在 30-45 秒更利于完播。';
+    if (duration <= 60) return '该选题需要解释方法或步骤,45-60 秒能兼顾信息量和节奏。';
+    return '该选题更像案例/故事型内容,60-90 秒更适合铺陈和转折。';
+  }
+
+  private asNumber(value: unknown, fallback: number): number {
+    const num = Number(value);
+    return Number.isFinite(num) ? num : fallback;
+  }
+
+  private asText(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+}

+ 217 - 0
src/app/services/ip-topic-script-generator.service.spec.ts

@@ -0,0 +1,217 @@
+import { TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import { IpContentDirection, IpTopic } from '../models/ip-operator.model';
+import { IpTopicScriptGeneratorService } from './ip-topic-script-generator.service';
+import { LlmService } from './llm.service';
+
+describe('IpTopicScriptGeneratorService', () => {
+  let service: IpTopicScriptGeneratorService;
+  let llm: { askWithSystem: ReturnType<typeof vi.fn> };
+
+  beforeEach(() => {
+    llm = { askWithSystem: vi.fn() };
+    TestBed.configureTestingModule({
+      providers: [
+        IpTopicScriptGeneratorService,
+        { provide: LlmService, useValue: llm },
+      ],
+    });
+    service = TestBed.inject(IpTopicScriptGeneratorService);
+  });
+
+  it('generates a validated executable script from account context through LLM', async () => {
+    llm.askWithSystem.mockReturnValue(of(JSON.stringify(validScriptResponse())));
+
+    const result = await service.generate(contextFixture());
+
+    expect(result.sourceMode).toBe('llm');
+    expect(result.script.qualityLevel).toBe('ready_to_record');
+    expect(result.script.shotList).toHaveLength(4);
+    expect(result.script.shotList?.[0]).toMatchObject({ startSecond: 0, endSecond: 3 });
+    expect(result.script.scriptSummary).toContain('账号定位');
+    const prompt = llm.askWithSystem.mock.calls[0][1] as string;
+    expect(prompt).toContain('老板 IP 实操拆解');
+    expect(prompt).toContain('评论反复询问第一步怎么做');
+    expect(prompt).toContain('retrospectiveConstraints');
+    expect(prompt).toContain('avoid repeating a failed generic hook');
+    expect(llm.askWithSystem.mock.calls[0][0]).toContain('不是逐字同步字幕');
+  });
+
+  it('marks local fallback as polish-first when LLM fails', async () => {
+    llm.askWithSystem.mockReturnValue(throwError(() => new Error('upstream unavailable')));
+
+    const result = await service.generate(contextFixture());
+
+    expect(result.sourceMode).toBe('local_fallback');
+    expect(result.failureReason).toContain('upstream unavailable');
+    expect(result.script.qualityLevel).toBe('polish_first');
+    expect(result.script.scriptSummary).toContain('本地兜底草稿');
+    expect(result.script.shotList?.length).toBeGreaterThanOrEqual(4);
+  });
+
+  it('rejects incomplete LLM output and returns an explicit fallback', async () => {
+    llm.askWithSystem.mockReturnValue(of(JSON.stringify({
+      ...validScriptResponse(),
+      shotList: validScriptResponse().shotList.slice(0, 2),
+    })));
+
+    const result = await service.generate(contextFixture());
+
+    expect(result.sourceMode).toBe('local_fallback');
+    expect(result.failureReason).toContain('少于 4 个');
+  });
+
+  it('asks LLM to repair screen emphasis text that duplicates narration', async () => {
+    const invalid = validScriptResponse();
+    invalid.shotList = invalid.shotList.map((item) => ({ ...item, subtitle: item.narration }));
+    llm.askWithSystem
+      .mockReturnValueOnce(of(JSON.stringify(invalid)))
+      .mockReturnValueOnce(of(JSON.stringify(validScriptResponse())));
+
+    const result = await service.generate(contextFixture());
+
+    expect(result.sourceMode).toBe('llm');
+    expect(llm.askWithSystem).toHaveBeenCalledTimes(2);
+    expect(llm.askWithSystem.mock.calls[1][1]).toContain('画面重点字');
+    expect(result.script.shotList?.every((shot) => shot.subtitle !== shot.narration)).toBe(true);
+  });
+});
+
+function contextFixture() {
+  return {
+    positioning: {
+      id: 'positioning-1',
+      accountId: 'account-1',
+      version: 1,
+      status: 'active' as const,
+      targetAudience: '刚开始做内容的传统行业老板',
+      persona: '讲真实项目的老板教练',
+      followReason: '把复杂运营问题拆成能执行的步骤',
+      contentPillars: ['老板 IP 实操拆解'],
+      expressionStyle: ['直接', '案例化'],
+      boundaries: ['不承诺确定收益'],
+      suitableViralPatterns: ['问题开场'],
+      evidenceItemIds: ['evidence-1'],
+      assumptionsToValidate: [],
+      observeMetrics: ['收藏', '评论'],
+      createdAt: '2026-07-06T00:00:00.000Z',
+      updatedAt: '2026-07-06T00:00:00.000Z',
+    },
+    direction: directionFixture(),
+    topic: topicFixture(),
+    strategyReport: {
+      id: 'report-1',
+      accountId: 'account-1',
+      snapshotId: 'snapshot-1',
+      positioningProposalId: 'proposal-1',
+      sourceMode: 'llm' as const,
+      sourceLabel: 'LLM 证据诊断',
+      diagnosisSummary: {
+        accountSnapshot: '教程内容互动较高',
+        currentIpGuess: 'AI 工具实操教练',
+        actualAudience: 'AI 初学者和职场用户',
+        positioningMismatch: '案例较少',
+        strongestFollowReason: '步骤清晰且能直接操作',
+        biggestGrowthBlocker: '缺少系列化',
+        highInteractionPattern: '问题开场加操作演示',
+        commentPainSummary: '评论反复询问第一步怎么做',
+        sevenDayPriority: '连续测试实操拆解',
+      },
+      evidenceRefs: [],
+      workReports: [],
+      directions: [],
+      taskSuggestions: [],
+      createdAt: '2026-07-06T00:00:00.000Z',
+      updatedAt: '2026-07-06T00:00:00.000Z',
+    },
+    evidenceLabels: ['高互动作品:老板 IP 第一步'],
+    retrospectiveConstraints: [{
+      type: 'hook' as const,
+      instruction: 'start with a concrete user scene',
+      avoid: 'avoid repeating a failed generic hook',
+    }],
+  };
+}
+
+function directionFixture(): IpContentDirection {
+  return {
+    id: 'direction-1',
+    accountId: 'account-1',
+    positioningVersionId: 'positioning-1',
+    role: 'trust',
+    title: '老板 IP 实操拆解',
+    targetAudience: '刚开始做内容的传统行业老板',
+    purpose: '通过真实操作建立专业信任',
+    sourceEvidenceIds: ['evidence-1'],
+    viralPatternRefs: ['问题开场'],
+    commentPainRefs: ['pain-1'],
+    topicIds: ['topic-1'],
+    cadenceSuggestion: '每周 2 条',
+    observeMetrics: ['收藏', '评论'],
+    fitReason: '历史教程内容收藏更高',
+    risks: ['避免泛泛讲方法'],
+    createdAt: '2026-07-06T00:00:00.000Z',
+    updatedAt: '2026-07-06T00:00:00.000Z',
+  };
+}
+
+function topicFixture(): IpTopic {
+  return {
+    id: 'topic-1',
+    directionId: 'direction-1',
+    title: '老板第一次做 IP,先完成哪一步',
+    column: '老板 IP 实操拆解',
+    trafficLayer: 'vertical',
+    contentGoal: '收藏与关注',
+    targetAudience: '刚开始做内容的传统行业老板',
+    userProblem: '不知道定位、选题和拍摄应该先做什么',
+    evidenceRefs: ['evidence-1'],
+    viralPatternRefs: ['问题开场'],
+    expectedRole: 'trust',
+    priority: 'high',
+    fitReason: '评论中持续出现起步顺序问题',
+    source: 'LLM 账号策略诊断',
+    requiredMaterials: ['账号主页录屏'],
+    scriptStatus: 'needs_input',
+    riskNote: '不承诺短期涨粉结果',
+  };
+}
+
+function validScriptResponse() {
+  return {
+    title: '老板 IP 第一步:先写清关注理由',
+    recommendedDurationSeconds: 36,
+    durationReason: '一个问题、一个判断和一个三步方法可在 36 秒内完整表达',
+    hook: '第一次做老板 IP,先别急着拍,先回答用户为什么要关注你。',
+    painPoint: '不知道定位、选题和拍摄应该先做什么',
+    viewpoint: '第一步不是拍摄,而是写清目标人群、问题和关注理由。',
+    caseOrMethod: '展示账号简介改写前后对比,并给出三步检查法。',
+    goldenSentence: '用户看不懂你帮谁,再勤奋更新也只是路过。',
+    closingCta: '评论区留下你的行业,我帮你检查关注理由。',
+    fullScript: '第一次做老板 IP,先别急着拍。先写清你帮谁、解决什么问题、为什么值得持续关注。',
+    shootingTips: ['准备账号主页改写前后截图'],
+    subtitleHighlights: ['先写清关注理由', '人群 / 问题 / 价值'],
+    replaceableSlots: ['真实账号主页截图'],
+    riskNote: '不承诺短期涨粉结果',
+    productionNotes: ['拍摄前确认主页信息可公开'],
+    shotList: [
+      shot(0, 3, '问题钩子'),
+      shot(3, 12, '错误示范'),
+      shot(12, 28, '三步方法'),
+      shot(28, 36, '行动引导'),
+    ],
+  };
+}
+
+function shot(startSecond: number, endSecond: number, segmentTitle: string) {
+  return {
+    startSecond,
+    endSecond,
+    segmentTitle,
+    visualDesign: `${segmentTitle}对应的真人出镜和账号页面画面`,
+    narration: `${segmentTitle}对应的完整口播内容`,
+    subtitle: segmentTitle,
+    actionNotes: '主播指向屏幕中的关键词,镜头缓慢推进',
+    audioNotes: '口播优先,关键词出现时加轻提示音',
+  };
+}

+ 316 - 0
src/app/services/ip-topic-script-generator.service.ts

@@ -0,0 +1,316 @@
+import { Injectable, inject } from '@angular/core';
+import { firstValueFrom, timeout } from 'rxjs';
+import {
+  IpAccountStrategyReport,
+  IpContentDirection,
+  IpPositioningVersion,
+  IpRetrospectiveScriptConstraint,
+  IpScript,
+  IpTopic,
+} from '../models/ip-operator.model';
+import { IpScriptWorkbenchService } from './ip-script-workbench.service';
+import { LlmService } from './llm.service';
+
+const SCRIPT_LLM_TIMEOUT_MS = 90_000;
+const SCRIPT_LLM_MAX_TOKENS = 5_000;
+
+export interface IpTopicScriptGenerationContext {
+  positioning?: IpPositioningVersion | null;
+  direction: IpContentDirection;
+  topic: IpTopic;
+  strategyReport?: IpAccountStrategyReport | null;
+  evidenceLabels: string[];
+  retrospectiveConstraints?: IpRetrospectiveScriptConstraint[];
+}
+
+export interface IpTopicScriptGenerationResult {
+  sourceMode: 'llm' | 'local_fallback';
+  script: Omit<IpScript, 'id' | 'topicId'>;
+  failureReason?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class IpTopicScriptGeneratorService {
+  private readonly llm = inject(LlmService);
+  private readonly scriptWorkbench = inject(IpScriptWorkbenchService);
+
+  async generate(context: IpTopicScriptGenerationContext): Promise<IpTopicScriptGenerationResult> {
+    let raw = '';
+    try {
+      raw = await this.requestScript(this.systemPrompt(), this.userPrompt(context));
+    } catch (error) {
+      const failureReason = this.failureMessage(error);
+      return {
+        sourceMode: 'local_fallback',
+        failureReason,
+        script: this.buildFallback(context, failureReason),
+      };
+    }
+
+    try {
+      return {
+        sourceMode: 'llm',
+        script: this.normalizeLlmScript(raw, context),
+      };
+    } catch (validationError) {
+      const firstFailure = this.failureMessage(validationError);
+      try {
+        const repaired = await this.requestScript(
+          this.systemPrompt(),
+          this.repairPrompt(raw, firstFailure),
+        );
+        return {
+          sourceMode: 'llm',
+          script: this.normalizeLlmScript(repaired, context),
+        };
+      } catch (repairError) {
+        const failureReason = `${firstFailure};修正失败:${this.failureMessage(repairError)}`;
+        return {
+          sourceMode: 'local_fallback',
+          failureReason,
+          script: this.buildFallback(context, failureReason),
+        };
+      }
+    }
+  }
+
+  private async requestScript(systemPrompt: string, userPrompt: string): Promise<string> {
+    return firstValueFrom(this.llm.askWithSystem(
+      systemPrompt,
+      userPrompt,
+      { temperature: 0.25, max_tokens: SCRIPT_LLM_MAX_TOKENS },
+    ).pipe(timeout(SCRIPT_LLM_TIMEOUT_MS)));
+  }
+
+  private repairPrompt(raw: string, failureReason: string): string {
+    return [
+      '上一版执行脚本未通过结构校验,请只修正问题并重新输出完整 JSON。',
+      `校验问题:${failureReason}`,
+      '特别注意:narration 是主播完整说出的口播台词;subtitle 是画面重点字,不是同步字幕。',
+      '每条画面重点字应提炼当前分镜的核心信息,建议 4-18 个汉字,不能直接复制整句口播。',
+      '上一版输出:',
+      raw,
+    ].join('\n');
+  }
+
+  private systemPrompt(): string {
+    return [
+      '你是短视频 IP 运营执行脚本导演。',
+      '必须根据给定账号定位、内容方向、选题、作品分析和评论证据生成原创可拍摄脚本,不能套用通用模板。',
+      '不得直接复制作品文字、评论原句或对标账号话术;评论只能作为用户问题证据。',
+      '只返回一个合法 JSON 对象,不要 Markdown、代码围栏或解释。',
+      '脚本必须有 4-8 个连续分镜,第一镜从 0 秒开始且覆盖前 3 秒。',
+      '每个分镜必须明确秒数、画面、动作、口播台词、画面重点字和音效;禁止使用“自行补充”“根据情况调整”等占位表达。',
+      'narration 是主播完整说出的口播台词。subtitle 是画面重点字,不是逐字同步字幕;应提炼为 4-18 个汉字,不能复制整句口播。',
+    ].join('\n');
+  }
+
+  private userPrompt(context: IpTopicScriptGenerationContext): string {
+    const positioning = context.positioning
+      ? {
+          persona: context.positioning.persona,
+          targetAudience: context.positioning.targetAudience,
+          followReason: context.positioning.followReason,
+          contentPillars: context.positioning.contentPillars,
+          expressionStyle: context.positioning.expressionStyle,
+          boundaries: context.positioning.boundaries,
+        }
+      : null;
+    const workReports = (context.strategyReport?.workReports || []).slice(0, 3).map((report) => ({
+      title: report.title,
+      conclusions: report.reports.map((block) => ({
+        headline: block.headline,
+        summary: block.summary,
+        strategyJudgment: block.strategyJudgment,
+        evidenceSignals: block.evidenceSignals,
+      })),
+      commentSamples: (report.supportingCommentSamples || []).slice(0, 8).map((item) => item.text),
+    }));
+    const input = {
+      positioning,
+      direction: {
+        role: context.direction.role,
+        title: context.direction.title,
+        targetAudience: context.direction.targetAudience,
+        purpose: context.direction.purpose,
+        cadenceSuggestion: context.direction.cadenceSuggestion,
+        observeMetrics: context.direction.observeMetrics,
+        fitReason: context.direction.fitReason,
+        risks: context.direction.risks,
+      },
+      topic: {
+        title: context.topic.title,
+        targetAudience: context.topic.targetAudience,
+        userProblem: context.topic.userProblem,
+        contentGoal: context.topic.contentGoal,
+        fitReason: context.topic.fitReason,
+        requiredMaterials: context.topic.requiredMaterials,
+        riskNote: context.topic.riskNote,
+      },
+      accountSignals: context.strategyReport
+        ? {
+            currentIpGuess: context.strategyReport.diagnosisSummary.currentIpGuess,
+            actualAudience: context.strategyReport.diagnosisSummary.actualAudience,
+            strongestFollowReason: context.strategyReport.diagnosisSummary.strongestFollowReason,
+            highInteractionPattern: context.strategyReport.diagnosisSummary.highInteractionPattern,
+            commentPainSummary: context.strategyReport.diagnosisSummary.commentPainSummary,
+          }
+        : null,
+      workReports,
+      evidenceLabels: context.evidenceLabels.slice(0, 12),
+      retrospectiveConstraints: (context.retrospectiveConstraints || []).slice(0, 8),
+    };
+    return [
+      '请依据以下真实账号上下文生成一条执行脚本:',
+      JSON.stringify(input),
+      '如果 retrospectiveConstraints 不为空,必须把它当作上一轮真实发布复盘后的硬约束继承到本次脚本里;不能重复上一轮已经暴露的问题。',
+      '返回结构:',
+      JSON.stringify({
+        title: '脚本标题',
+        recommendedDurationSeconds: 60,
+        durationReason: '为什么适合这个时长',
+        hook: '前3秒完整口播',
+        painPoint: '具体用户问题',
+        viewpoint: '账号要表达的核心判断',
+        caseOrMethod: '案例或方法展开',
+        goldenSentence: '可记忆金句',
+        closingCta: '与内容目标一致的结尾行动',
+        fullScript: '完整连贯口播稿',
+        shootingTips: ['拍摄提示'],
+        subtitleHighlights: ['重点字幕'],
+        replaceableSlots: ['需要替换的真实素材位'],
+        riskNote: '风险边界',
+        productionNotes: ['执行前准备'],
+        shotList: [{
+          startSecond: 0,
+          endSecond: 3,
+          segmentTitle: '分镜名称',
+          visualDesign: '具体景别、场景和屏幕内容',
+          narration: '主播在这一段完整说出的口播台词',
+          subtitle: '画面重点字,4-18个汉字,不复制口播',
+          actionNotes: '人物动作和镜头动作',
+          audioNotes: '音乐、音效或转场',
+        }],
+      }),
+    ].join('\n');
+  }
+
+  private normalizeLlmScript(raw: string, context: IpTopicScriptGenerationContext): Omit<IpScript, 'id' | 'topicId'> {
+    const parsed = JSON.parse(this.extractJson(raw)) as Record<string, unknown>;
+    const shots = this.scriptWorkbench.normalizeShots(parsed['shotList'] || parsed['shots']);
+    if (shots.length < 4) throw new Error('LLM 返回的有效分镜少于 4 个');
+    const sortedShots = [...shots].sort((left, right) => left.startSecond - right.startSecond);
+    if (sortedShots[0].startSecond !== 0 || sortedShots[0].endSecond < 3) {
+      throw new Error('LLM 返回的首个分镜未覆盖 0-3 秒');
+    }
+    if (sortedShots.some((shot) => shot.endSecond <= shot.startSecond || !shot.visualDesign || !shot.narration || !shot.subtitle || !shot.actionNotes)) {
+      throw new Error('LLM 返回的分镜缺少可执行字段或秒数无效');
+    }
+    if (sortedShots.some((shot) => this.screenTextInvalid(shot.narration, shot.subtitle))) {
+      throw new Error('画面重点字与口播重复或过长');
+    }
+    for (let index = 1; index < sortedShots.length; index += 1) {
+      if (sortedShots[index].startSecond !== sortedShots[index - 1].endSecond) {
+        throw new Error('LLM 返回的分镜时间轴不连续');
+      }
+    }
+    const finalEndSecond = sortedShots[sortedShots.length - 1].endSecond;
+    const duration = this.asDuration(parsed['recommendedDurationSeconds'], finalEndSecond);
+    if (Math.abs(duration - finalEndSecond) > 1) throw new Error('建议时长与分镜时间轴不一致');
+
+    return {
+      type: 'full',
+      title: this.text(parsed['title']) || `${context.topic.title}|执行分镜`,
+      hook: this.requiredText(parsed['hook'], '开头钩子'),
+      painPoint: this.requiredText(parsed['painPoint'], '用户痛点'),
+      viewpoint: this.requiredText(parsed['viewpoint'], '核心观点'),
+      caseOrMethod: this.requiredText(parsed['caseOrMethod'], '案例或方法'),
+      goldenSentence: this.requiredText(parsed['goldenSentence'], '金句'),
+      closingCta: this.requiredText(parsed['closingCta'], '结尾引导'),
+      fullScript: this.requiredText(parsed['fullScript'], '完整口播稿'),
+      shootingTips: this.stringList(parsed['shootingTips']),
+      subtitleHighlights: this.stringList(parsed['subtitleHighlights']),
+      replaceableSlots: this.stringList(parsed['replaceableSlots']),
+      riskNote: this.text(parsed['riskNote']) || context.topic.riskNote || '发布前核对事实、案例授权和平台表达风险。',
+      qualityLevel: 'ready_to_record',
+      scriptSummary: 'LLM 已结合当前账号定位、作品报告与用户反馈生成执行脚本。',
+      productionNotes: this.stringList(parsed['productionNotes']),
+      shotList: sortedShots,
+      recommendedDurationSeconds: duration,
+      durationReason: this.requiredText(parsed['durationReason'], '时长理由'),
+      userEdited: false,
+    };
+  }
+
+  private buildFallback(context: IpTopicScriptGenerationContext, failureReason: string): Omit<IpScript, 'id' | 'topicId'> {
+    const fallback = this.scriptWorkbench.buildFallbackScript({
+      title: context.topic.title,
+      contentGoal: context.topic.contentGoal,
+      directionRole: context.direction.role,
+    });
+    return {
+      type: 'full',
+      title: `${context.topic.title}|兜底草稿`,
+      hook: fallback.shots[0]?.narration || context.topic.title,
+      painPoint: context.topic.userProblem || context.topic.contentGoal,
+      viewpoint: fallback.shots[2]?.narration || context.topic.fitReason,
+      caseOrMethod: fallback.shots[3]?.narration || context.topic.fitReason,
+      goldenSentence: '先让用户知道你在帮谁,再让用户相信你能帮他。',
+      closingCta: fallback.shots[fallback.shots.length - 1]?.narration || '评论区留下你最卡的问题。',
+      fullScript: fallback.shots.map((shot) => `${shot.startSecond}-${shot.endSecond}秒 ${shot.narration}`).join('\n'),
+      shootingTips: fallback.shots.map((shot) => `${shot.segmentTitle}:${shot.visualDesign}`),
+      subtitleHighlights: fallback.shots.map((shot) => shot.subtitle).filter(Boolean),
+      replaceableSlots: ['真实账号画面', '真实案例或操作截图', '评论区问题'],
+      riskNote: fallback.riskNotes.join(';'),
+      qualityLevel: 'polish_first',
+      scriptSummary: `LLM 生成未完成,当前为本地兜底草稿:${failureReason}`,
+      productionNotes: fallback.optimizationSuggestions,
+      shotList: fallback.shots,
+      recommendedDurationSeconds: fallback.recommendedDurationSeconds,
+      durationReason: fallback.durationReason,
+      userEdited: false,
+    };
+  }
+
+  private extractJson(raw: string): string {
+    const text = String(raw || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+    const start = text.indexOf('{');
+    const end = text.lastIndexOf('}');
+    if (start < 0 || end <= start) throw new Error('LLM 响应中没有完整 JSON');
+    return text.slice(start, end + 1);
+  }
+
+  private requiredText(value: unknown, field: string): string {
+    const result = this.text(value);
+    if (!result) throw new Error(`LLM 响应缺少${field}`);
+    return result;
+  }
+
+  private text(value: unknown): string {
+    return typeof value === 'string' ? value.trim() : '';
+  }
+
+  private stringList(value: unknown): string[] {
+    return Array.isArray(value) ? value.map((item) => this.text(item)).filter(Boolean) : [];
+  }
+
+  private screenTextInvalid(narration: string, screenText: string): boolean {
+    const normalize = (value: string) => value.replace(/[\s,。!?、,.!?::;;“”"']/g, '').trim();
+    const narrationText = normalize(narration);
+    const screenTextValue = normalize(screenText);
+    return !screenTextValue
+      || screenTextValue.length > 18
+      || screenTextValue === narrationText
+      || (narrationText.length > 12 && screenTextValue.length >= narrationText.length * 0.8);
+  }
+
+  private asDuration(value: unknown, fallback: number): number {
+    const parsed = Number(value);
+    return Math.max(15, Math.min(180, Number.isFinite(parsed) ? parsed : fallback));
+  }
+
+  private failureMessage(error: unknown): string {
+    const message = error instanceof Error ? error.message : String(error || '未知错误');
+    return message.replace(/\s+/g, ' ').trim().slice(0, 180) || '未知错误';
+  }
+}

+ 105 - 0
src/app/services/reference-video-prompt-planner.service.spec.ts

@@ -0,0 +1,105 @@
+import { firstValueFrom, of, throwError } from 'rxjs';
+import { ReferenceVideoPromptPlannerService } from './reference-video-prompt-planner.service';
+
+describe('ReferenceVideoPromptPlannerService', () => {
+  it('能够分析参考图并返回结构化的图片/视频提示词', async () => {
+    const llm = {
+      urlToBase64: vi.fn().mockResolvedValue({ base64: 'base64-image', mimeType: 'image/png' }),
+      analyzeImage: vi.fn().mockReturnValue(of(JSON.stringify({
+        mainSubject: '透明香水瓶',
+        identityFeatures: ['方形瓶身', '银色瓶盖', '透明玻璃材质'],
+        visualStyle: '商业摄影',
+        colorsAndLighting: '冷色霓虹光',
+        composition: '居中产品特写',
+        objects: ['香水瓶', '玻璃台面'],
+        doNotChange: ['瓶身轮廓', '瓶盖结构'],
+      }))),
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({
+        content: JSON.stringify({
+          intentSummary: '雨夜城市橱窗香水广告',
+          referenceKeep: ['保留方形瓶身', '保留银色瓶盖'],
+          imagePrompt: '以参考图为视觉参考,保留方形瓶身和银色瓶盖,但不要直接复制原图构图。生成雨夜城市橱窗香水广告静帧。',
+          videoPrompt: '基于首帧画面生成广告短片。镜头缓慢推进,霓虹反射流动,水珠轻微滑落。',
+          negativePrompt: '不要改变香水瓶造型,不要出现水印和多余文字。',
+          qualityHints: {
+            composition: '产品居中偏下,近景特写',
+            style: '高级商业广告',
+            lighting: '冷色霓虹和玻璃反射',
+            aspectRatioSuggestion: '16:9',
+          },
+          riskWarnings: [],
+        }),
+        finishReason: 'stop',
+        response: {} as any,
+      })),
+    };
+
+    const service = new ReferenceVideoPromptPlannerService(llm as any);
+
+    const plan = await firstValueFrom(service.plan({
+      referenceImageUrl: 'https://example.com/ref.png',
+      userPrompt: '参考这张香水瓶,生成雨夜城市橱窗广告,镜头慢慢推进。',
+      aspect: '16:9',
+    }));
+
+    expect(llm.urlToBase64).toHaveBeenCalledWith('https://example.com/ref.png');
+    expect(llm.analyzeImage).toHaveBeenCalledWith(
+      'base64-image',
+      'image/png',
+      expect.stringContaining('输出严格的 JSON'),
+      expect.objectContaining({ model: 'gemini-2.5-flash' }),
+    );
+    expect(llm.askWithSystemDetailed).toHaveBeenCalledWith(
+      expect.stringContaining('参考图生视频'),
+      expect.stringContaining('参考这张香水瓶'),
+      expect.objectContaining({ temperature: 0.35 }),
+    );
+    expect(plan.imagePrompt).toContain('不要直接复制原图构图');
+    expect(plan.videoPrompt).toContain('镜头缓慢推进');
+    expect(plan.referenceKeep).toEqual(['保留方形瓶身', '保留银色瓶盖']);
+    expect(plan.source).toBe('llm');
+  });
+
+  it('LLM 返回 markdown 代码块时也能提取 JSON', async () => {
+    const llm = {
+      urlToBase64: vi.fn().mockResolvedValue({ base64: 'base64-image', mimeType: 'image/png' }),
+      analyzeImage: vi.fn().mockReturnValue(of('{"mainSubject":"人物","identityFeatures":["黑色短发"],"visualStyle":"写实","colorsAndLighting":"自然光","composition":"半身","objects":[],"doNotChange":["发型"]}')),
+      askWithSystemDetailed: vi.fn().mockReturnValue(of({
+        content: '```json\n{"intentSummary":"人物短片","referenceKeep":["保留黑色短发"],"imagePrompt":"以参考图为视觉参考,保留黑色短发,但不要直接复制原图构图。生成街头写真静帧。","videoPrompt":"基于首帧画面生成自然动态视频。人物轻微转头,背景光影变化。","negativePrompt":"不要改变身份。","qualityHints":{"composition":"半身近景","style":"写实摄影","lighting":"自然光","aspectRatioSuggestion":"9:16"},"riskWarnings":[]}\n```',
+        finishReason: 'stop',
+        response: {} as any,
+      })),
+    };
+
+    const service = new ReferenceVideoPromptPlannerService(llm as any);
+    const plan = await firstValueFrom(service.plan({
+      referenceImageUrl: 'https://example.com/person.png',
+      userPrompt: '生成街头写真视频',
+      aspect: '9:16',
+    }));
+
+    expect(plan.intentSummary).toBe('人物短片');
+    expect(plan.qualityHints.aspectRatioSuggestion).toBe('9:16');
+  });
+
+  it('LLM 规划失败时返回稳定兜底提示词', async () => {
+    const llm = {
+      urlToBase64: vi.fn().mockResolvedValue({ base64: 'base64-image', mimeType: 'image/png' }),
+      analyzeImage: vi.fn().mockReturnValue(of('{"mainSubject":"咖啡杯","identityFeatures":["白色陶瓷杯"],"visualStyle":"静物摄影","colorsAndLighting":"暖光","composition":"桌面近景","objects":["咖啡杯"],"doNotChange":["杯子形状"]}')),
+      askWithSystemDetailed: vi.fn().mockReturnValue(throwError(() => new Error('LLM unavailable'))),
+    };
+
+    const service = new ReferenceVideoPromptPlannerService(llm as any);
+    const plan = await firstValueFrom(service.plan({
+      referenceImageUrl: 'https://example.com/cup.png',
+      userPrompt: '让咖啡杯在清晨阳光下有热气升起,镜头缓慢推进',
+      aspect: '16:9',
+    }));
+
+    expect(plan.source).toBe('fallback');
+    expect(plan.imagePrompt).toContain('以参考图为视觉参考');
+    expect(plan.imagePrompt).toContain('白色陶瓷杯');
+    expect(plan.videoPrompt).toContain('热气升起');
+    expect(plan.riskWarnings.join(' ')).toContain('LLM unavailable');
+  });
+});

+ 273 - 0
src/app/services/reference-video-prompt-planner.service.ts

@@ -0,0 +1,273 @@
+import { Injectable } from '@angular/core';
+import { Observable, from, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+import { JimengAspectRatio } from './jimeng.service';
+import { LlmService } from './llm.service';
+
+export interface ReferenceImageBrief {
+  mainSubject: string;
+  identityFeatures: string[];
+  visualStyle: string;
+  colorsAndLighting: string;
+  composition: string;
+  objects: string[];
+  doNotChange: string[];
+}
+
+export interface ReferenceVideoPlanQualityHints {
+  composition: string;
+  style: string;
+  lighting: string;
+  aspectRatioSuggestion: JimengAspectRatio;
+}
+
+export interface ReferenceVideoPromptPlan {
+  intentSummary: string;
+  referenceKeep: string[];
+  imagePrompt: string;
+  videoPrompt: string;
+  negativePrompt: string;
+  qualityHints: ReferenceVideoPlanQualityHints;
+  riskWarnings: string[];
+  imageBrief: ReferenceImageBrief;
+  source: 'llm' | 'fallback';
+}
+
+export interface ReferenceVideoPromptPlanInput {
+  referenceImageUrl: string;
+  userPrompt: string;
+  aspect: JimengAspectRatio;
+}
+
+const ASPECTS: JimengAspectRatio[] = ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
+
+@Injectable({ providedIn: 'root' })
+export class ReferenceVideoPromptPlannerService {
+  constructor(private llm: LlmService) {}
+
+  plan(input: ReferenceVideoPromptPlanInput): Observable<ReferenceVideoPromptPlan> {
+    const referenceImageUrl = String(input.referenceImageUrl || '').trim();
+    const userPrompt = String(input.userPrompt || '').trim();
+    if (!referenceImageUrl) {
+      throw new Error('缺少参考图,无法规划参考图生成视频');
+    }
+    if (!userPrompt) {
+      throw new Error('缺少提示词,无法规划参考图生成视频');
+    }
+
+    return from(this.llm.urlToBase64(referenceImageUrl)).pipe(
+      switchMap(({ base64, mimeType }) => this.llm.analyzeImage(
+        base64,
+        mimeType,
+        this.imageAnalysisPrompt(),
+        {
+          model: 'gemini-2.5-flash',
+          generationConfig: { temperature: 0.25, maxOutputTokens: 1600 },
+        },
+      )),
+      map((text) => this.parseImageBrief(text)),
+      switchMap((brief) => this.llm.askWithSystemDetailed(
+        this.plannerSystemPrompt(),
+        this.plannerUserPrompt(brief, userPrompt, input.aspect),
+        { model: 'gpt-4o-mini', temperature: 0.35, max_tokens: 1800 },
+      ).pipe(
+        map((result) => this.parsePlan(result.content, brief, input.aspect)),
+        catchError((error) => of(this.fallbackPlan(brief, userPrompt, input.aspect, error))),
+      )),
+      catchError((error) => {
+        const emptyBrief = this.emptyBrief();
+        return of(this.fallbackPlan(emptyBrief, userPrompt, input.aspect, error));
+      }),
+    );
+  }
+
+  private imageAnalysisPrompt(): string {
+    return `请分析这张参考图,输出严格的 JSON,不要 markdown,不要解释。
+{
+  "mainSubject": "主体是什么,30字内",
+  "identityFeatures": ["必须保留的主体特征,每条20字内"],
+  "visualStyle": "画风/摄影风格/质感,30字内",
+  "colorsAndLighting": "色彩和光线,40字内",
+  "composition": "构图、景别、视角,40字内",
+  "objects": ["重要物体"],
+  "doNotChange": ["不应改变的内容"]
+}
+只描述图中可见内容,不要推测品牌、身份或不可见信息。`;
+  }
+
+  private plannerSystemPrompt(): string {
+    return `你是“参考图生视频”的提示词规划器。
+任务:根据参考图摘要和用户需求,输出严格 JSON,用于两段式生成。
+第一段 imagePrompt 给图片生成模型:参考图只是视觉参考,生成一张新的、适合作为视频首帧的图片。
+第二段 videoPrompt 给图生视频模型:基于新首帧生成自然动态视频。
+规则:
+1. imagePrompt 必须包含“以参考图为视觉参考”和“不要直接复制原图构图”。
+2. imagePrompt 必须说明保留参考图核心主体、关键外观、材质、风格或构图关系。
+3. videoPrompt 只描述主体动作、环境变化、镜头运动和一致性约束。
+4. videoPrompt 不要引入 imagePrompt 没有建立的主体、场景或风格。
+5. negativePrompt 写成一句中文,包含身份/主体/产品形态/水印文字限制。
+6. 不要输出 markdown,不要解释,只输出 JSON。
+7. 输出字段必须完整。
+JSON 结构:
+{
+  "intentSummary": "",
+  "referenceKeep": [],
+  "imagePrompt": "",
+  "videoPrompt": "",
+  "negativePrompt": "",
+  "qualityHints": {
+    "composition": "",
+    "style": "",
+    "lighting": "",
+    "aspectRatioSuggestion": "16:9"
+  },
+  "riskWarnings": []
+}`;
+  }
+
+  private plannerUserPrompt(brief: ReferenceImageBrief, userPrompt: string, aspect: JimengAspectRatio): string {
+    return [
+      `【参考图摘要】${JSON.stringify(brief)}`,
+      `【用户需求】${userPrompt}`,
+      `【当前画面比例】${aspect}`,
+      '请输出严格 JSON。',
+    ].join('\n');
+  }
+
+  private parseImageBrief(text: string): ReferenceImageBrief {
+    const parsed = this.parseJsonObject(text);
+    return {
+      mainSubject: this.clean(parsed['mainSubject']),
+      identityFeatures: this.cleanList(parsed['identityFeatures']).slice(0, 8),
+      visualStyle: this.clean(parsed['visualStyle']),
+      colorsAndLighting: this.clean(parsed['colorsAndLighting']),
+      composition: this.clean(parsed['composition']),
+      objects: this.cleanList(parsed['objects']).slice(0, 8),
+      doNotChange: this.cleanList(parsed['doNotChange']).slice(0, 8),
+    };
+  }
+
+  private parsePlan(text: string, brief: ReferenceImageBrief, fallbackAspect: JimengAspectRatio): ReferenceVideoPromptPlan {
+    const parsed = this.parseJsonObject(text);
+    const hints = parsed['qualityHints'] && typeof parsed['qualityHints'] === 'object'
+      ? parsed['qualityHints'] as Record<string, unknown>
+      : {};
+    const plan: ReferenceVideoPromptPlan = {
+      intentSummary: this.clean(parsed['intentSummary']) || '参考图生成视频',
+      referenceKeep: this.cleanList(parsed['referenceKeep']).slice(0, 8),
+      imagePrompt: this.clean(parsed['imagePrompt']),
+      videoPrompt: this.clean(parsed['videoPrompt']),
+      negativePrompt: this.clean(parsed['negativePrompt']),
+      qualityHints: {
+        composition: this.clean(hints['composition']),
+        style: this.clean(hints['style']),
+        lighting: this.clean(hints['lighting']),
+        aspectRatioSuggestion: this.normalizeAspect(hints['aspectRatioSuggestion'], fallbackAspect),
+      },
+      riskWarnings: this.cleanList(parsed['riskWarnings']).slice(0, 6),
+      imageBrief: brief,
+      source: 'llm',
+    };
+
+    if (!plan.imagePrompt || !plan.videoPrompt) {
+      throw new Error('LLM 规划缺少 imagePrompt 或 videoPrompt');
+    }
+    if (!plan.imagePrompt.includes('参考图')) {
+      plan.imagePrompt = `以参考图为视觉参考,${plan.imagePrompt}`;
+    }
+    if (!plan.imagePrompt.includes('不要直接复制原图构图')) {
+      plan.imagePrompt = `${plan.imagePrompt},但不要直接复制原图构图。`;
+    }
+    return plan;
+  }
+
+  private fallbackPlan(
+    brief: ReferenceImageBrief,
+    userPrompt: string,
+    aspect: JimengAspectRatio,
+    error: unknown,
+  ): ReferenceVideoPromptPlan {
+    const keep = [
+      brief.mainSubject ? `保留${brief.mainSubject}` : '',
+      ...brief.identityFeatures.map((item) => `保留${item}`),
+      ...brief.doNotChange.map((item) => `不要改变${item}`),
+    ].filter(Boolean).slice(0, 8);
+    const keepText = keep.length
+      ? keep.join(',')
+      : '保留参考图中的核心主体、主要外观、色彩关系和风格质感';
+    const styleText = [brief.visualStyle, brief.colorsAndLighting, brief.composition].filter(Boolean).join(',');
+    const message = error instanceof Error ? error.message : String(error || 'LLM 规划失败');
+
+    return {
+      intentSummary: userPrompt.slice(0, 40) || '参考图生成视频',
+      referenceKeep: keep,
+      imagePrompt: [
+        '以参考图为视觉参考',
+        keepText,
+        '但不要直接复制原图构图',
+        `根据用户需求生成一张清晰、主体完整、适合作为视频首帧的高清图片:${userPrompt}`,
+        styleText ? `参考视觉风格:${styleText}` : '',
+        '画面完整,无文字水印,无畸形结构。',
+      ].filter(Boolean).join(','),
+      videoPrompt: [
+        '基于首帧画面生成自然动态视频',
+        userPrompt,
+        '保持主体外观、场景风格和光线一致,动作自然流畅,不要改变主体身份,不要新增无关物体。',
+      ].filter(Boolean).join('。'),
+      negativePrompt: '不要改变主体身份、产品形态、关键结构,不要出现水印、字幕、乱码文字或无关物体。',
+      qualityHints: {
+        composition: brief.composition || '主体完整,适合作为视频首帧',
+        style: brief.visualStyle || '高质量视觉风格',
+        lighting: brief.colorsAndLighting || '自然统一的光线',
+        aspectRatioSuggestion: aspect,
+      },
+      riskWarnings: [`已使用保守兜底规划:${message}`],
+      imageBrief: brief,
+      source: 'fallback',
+    };
+  }
+
+  private parseJsonObject(text: string): Record<string, unknown> {
+    const raw = String(text || '').trim();
+    const cleaned = raw
+      .replace(/```(?:json)?/gi, '')
+      .replace(/```/g, '')
+      .trim();
+    const firstBrace = cleaned.indexOf('{');
+    const lastBrace = cleaned.lastIndexOf('}');
+    if (firstBrace < 0 || lastBrace <= firstBrace) {
+      throw new Error(`LLM 未返回 JSON 对象:${raw.slice(0, 200)}`);
+    }
+    const parsed = JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+      throw new Error('LLM 返回的 JSON 不是对象');
+    }
+    return parsed as Record<string, unknown>;
+  }
+
+  private emptyBrief(): ReferenceImageBrief {
+    return {
+      mainSubject: '',
+      identityFeatures: [],
+      visualStyle: '',
+      colorsAndLighting: '',
+      composition: '',
+      objects: [],
+      doNotChange: [],
+    };
+  }
+
+  private clean(value: unknown): string {
+    return String(value ?? '').replace(/\s+/g, ' ').trim();
+  }
+
+  private cleanList(value: unknown): string[] {
+    if (!Array.isArray(value)) return [];
+    return value.map((item) => this.clean(item)).filter(Boolean);
+  }
+
+  private normalizeAspect(value: unknown, fallback: JimengAspectRatio): JimengAspectRatio {
+    const raw = this.clean(value) as JimengAspectRatio;
+    return ASPECTS.includes(raw) ? raw : fallback;
+  }
+}

+ 109 - 0
src/app/services/retrospective.service.spec.ts

@@ -0,0 +1,109 @@
+import { StorageGovernanceService } from './storage-governance.service';
+import { RetrospectiveService } from './retrospective.service';
+import { PublicationRecord } from '../models/publication-record.model';
+
+const LEGACY_STORAGE_KEY = 'videoWorkflow.publicationRecords.v1';
+
+describe('RetrospectiveService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+  });
+
+  it('reads legacy records but writes new records through cloud and governed cache', async () => {
+    const legacy = record('pub-legacy', '旧复盘', '2026-06-01T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([legacy]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(cloud);
+
+    const saved = service.save({
+      assetKey: 'asset-new',
+      assetTitle: '新复盘',
+      assetUrl: 'https://example.com/new.mp4',
+      pipelineId: 'topic-to-video',
+      platform: 'douyin',
+      publishedAt: '2026-06-05T00:00:00.000Z',
+      metrics: { views: 1200, likes: 100, comments: 12, favorites: 20, shares: 6 },
+      rating: 4,
+      notes: '表现不错',
+    });
+    await Promise.resolve();
+
+    expect(cloud.upsert).toHaveBeenCalledWith('retrospective', saved.id, expect.objectContaining({
+      id: saved.id,
+      assetTitle: '新复盘',
+      score: expect.any(Number),
+    }));
+    expect(JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) || '[]')).toEqual([legacy]);
+    expect(localStorage.getItem('videoWorkflow.publicationRecords.cache.v1')).toContain('__storageGovernance');
+    expect(service.list().some((item) => item.id === 'pub-legacy')).toBe(true);
+    expect(service.list().some((item) => item.id === saved.id)).toBe(true);
+  });
+
+  it('syncs metric updates to cloud storage', async () => {
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([record('pub-1', '待更新复盘', '2026-06-01T00:00:00.000Z')]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(cloud);
+
+    const updated = service.updateMetrics('pub-1', { views: 3000, likes: 200, comments: 20, favorites: 80, shares: 12 });
+    await Promise.resolve();
+
+    expect(updated.metrics.views).toBe(3000);
+    expect(cloud.upsert).toHaveBeenCalledWith('retrospective', 'pub-1', expect.objectContaining({
+      metrics: expect.objectContaining({ views: 3000 }),
+      score: expect.any(Number),
+    }));
+  });
+
+  it('refreshes retrospective records from Parse cloud entities and keeps newer local records', async () => {
+    const localNewer = record('pub-same', '本地较新', '2026-06-05T00:00:00.000Z');
+    const cloudOlder = record('pub-same', '云端较旧', '2026-06-04T00:00:00.000Z');
+    const cloudOnly = record('pub-cloud', '云端记录', '2026-06-06T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([localNewer]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([{ data: cloudOlder }, { data: cloudOnly }]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(cloud);
+
+    const refreshed = await service.refreshFromCloud();
+
+    expect(cloud.list).toHaveBeenCalledWith('retrospective', 'active', 500);
+    expect(refreshed.map((item) => item.id).sort()).toEqual(['pub-cloud', 'pub-same']);
+    expect(refreshed.find((item) => item.id === 'pub-same')?.assetTitle).toBe('本地较新');
+    expect(service.list().some((item) => item.id === 'pub-cloud')).toBe(true);
+  });
+});
+
+function createService(cloud: any): RetrospectiveService {
+  return new RetrospectiveService(cloud, new StorageGovernanceService());
+}
+
+function record(id: string, title: string, updatedAt: string): PublicationRecord {
+  return {
+    id,
+    assetKey: `asset-${id}`,
+    assetTitle: title,
+    assetUrl: 'https://example.com/video.mp4',
+    pipelineId: 'topic-to-video',
+    projectName: '',
+    tags: ['复盘'],
+    platform: 'douyin',
+    publishedAt: updatedAt,
+    url: 'https://www.douyin.com/video/123',
+    metrics: { views: 1000, likes: 50, comments: 5, favorites: 10, shares: 2 },
+    score: 70,
+    rating: 3,
+    notes: '',
+    diagnosis: ['表现中性'],
+    suggestions: ['继续观察'],
+    createdAt: updatedAt,
+    updatedAt,
+  };
+}

+ 90 - 6
src/app/services/retrospective.service.ts

@@ -1,13 +1,25 @@
 import { Injectable } from '@angular/core';
 import { BehaviorSubject } from 'rxjs';
 import { PublicationMetrics, PublicationRecord, PublicationRecordInput } from '../models/publication-record.model';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
 
-const STORAGE_KEY = 'videoWorkflow.publicationRecords.v1';
+const LEGACY_STORAGE_KEY = 'videoWorkflow.publicationRecords.v1';
+const CACHE_STORAGE_KEY = 'videoWorkflow.publicationRecords.cache.v1';
+const RETROSPECTIVE_ENTITY_TYPE = 'retrospective';
 
 @Injectable({ providedIn: 'root' })
 export class RetrospectiveService {
-  private readonly recordsSubject = new BehaviorSubject<PublicationRecord[]>(this.restore());
+  private readonly recordsSubject = new BehaviorSubject<PublicationRecord[]>([]);
   readonly records$ = this.recordsSubject.asObservable();
+  private recordsCache: PublicationRecord[] | null = null;
+
+  constructor(
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
+  ) {
+    this.recordsSubject.next(this.restore());
+  }
 
   get records(): PublicationRecord[] {
     return this.recordsSubject.value;
@@ -17,6 +29,23 @@ export class RetrospectiveService {
     return [...this.records].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
   }
 
+  async refreshFromCloud(): Promise<PublicationRecord[]> {
+    const entities = await this.cloudStorage.list<PublicationRecord>(RETROSPECTIVE_ENTITY_TYPE, 'active', 500);
+    const cloudRecords = entities
+      .map((entity) => this.normalizeRecord(entity.data))
+      .filter((item): item is PublicationRecord => !!item);
+    const merged = new Map<string, PublicationRecord>();
+    for (const item of this.records) merged.set(item.id, item);
+    for (const item of cloudRecords) {
+      const local = merged.get(item.id);
+      const keepLocal = local && Date.parse(local.updatedAt || '') > Date.parse(item.updatedAt || '');
+      merged.set(item.id, keepLocal ? local : item);
+    }
+    const next = this.sortRecords(Array.from(merged.values()));
+    this.setRecords(next);
+    return this.list();
+  }
+
   save(input: PublicationRecordInput, id?: string): PublicationRecord {
     const current = id ? this.records.find((item) => item.id === id) : undefined;
     const now = new Date().toISOString();
@@ -47,6 +76,7 @@ export class RetrospectiveService {
       ? this.records.map((item) => item.id === record.id ? record : item)
       : [record, ...this.records];
     this.setRecords(next);
+    this.mirrorUpsert(record);
     return record;
   }
 
@@ -64,6 +94,7 @@ export class RetrospectiveService {
       updatedAt: new Date().toISOString(),
     };
     this.setRecords(this.records.map((item) => item.id === id ? next : item));
+    this.mirrorUpsert(next);
     return next;
   }
 
@@ -125,17 +156,70 @@ export class RetrospectiveService {
   }
 
   private restore(): PublicationRecord[] {
+    const governed = this.storageGovernance.readJson<PublicationRecord[]>(CACHE_STORAGE_KEY);
+    if (Array.isArray(governed)) {
+      this.recordsCache = this.sortRecords(governed.map((item) => this.normalizeRecord(item)).filter((item): item is PublicationRecord => !!item));
+      return this.recordsCache;
+    }
     try {
-      const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
-      return Array.isArray(parsed) ? parsed : [];
+      const parsed = JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) || '[]');
+      this.recordsCache = Array.isArray(parsed)
+        ? this.sortRecords(parsed.map((item) => this.normalizeRecord(item)).filter((item): item is PublicationRecord => !!item))
+        : [];
+      return this.recordsCache;
     } catch {
+      this.recordsCache = [];
       return [];
     }
   }
 
   private setRecords(records: PublicationRecord[]): void {
-    const sorted = [...records].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, 500);
+    const sorted = this.sortRecords(records).slice(0, 500);
+    this.recordsCache = sorted;
     this.recordsSubject.next(sorted);
-    localStorage.setItem(STORAGE_KEY, JSON.stringify(sorted));
+    const result = this.storageGovernance.writeJson(CACHE_STORAGE_KEY, sorted, 'cache');
+    if (!result.stored) {
+      console.warn('[RetrospectiveService] 复盘缓存写入失败,已保留内存快照:', result.reason);
+    }
+  }
+
+  private mirrorUpsert(record: PublicationRecord): void {
+    this.cloudStorage.upsert(RETROSPECTIVE_ENTITY_TYPE, record.id, record).catch((err) => {
+      console.warn('[RetrospectiveService] 云端同步复盘记录失败,已保留本地缓存:', err?.message || err);
+    });
+  }
+
+  private normalizeRecord(value: unknown): PublicationRecord | null {
+    const row = value as Partial<PublicationRecord> | null;
+    if (!row || !row.id || !row.assetKey || !row.assetTitle || !row.pipelineId || !row.platform) return null;
+    const now = new Date().toISOString();
+    const metrics = this.normalizeMetrics(row.metrics || { views: 0, likes: 0, comments: 0, favorites: 0, shares: 0 });
+    const rating = Number(row.rating || 0);
+    const score = Number.isFinite(Number(row.score)) ? Number(row.score) : this.score(metrics, rating);
+    return {
+      id: String(row.id),
+      assetKey: String(row.assetKey),
+      assetTitle: String(row.assetTitle),
+      assetUrl: row.assetUrl || '',
+      pipelineId: row.pipelineId,
+      templateId: row.templateId,
+      projectName: row.projectName || '',
+      tags: Array.isArray(row.tags) ? row.tags : [],
+      platform: row.platform,
+      publishedAt: row.publishedAt || now,
+      url: row.url || '',
+      metrics,
+      score,
+      rating,
+      notes: row.notes || '',
+      diagnosis: Array.isArray(row.diagnosis) ? row.diagnosis : this.diagnose(metrics, score),
+      suggestions: Array.isArray(row.suggestions) ? row.suggestions : this.suggest(row.assetTitle || '', metrics, score),
+      createdAt: row.createdAt || now,
+      updatedAt: row.updatedAt || row.createdAt || now,
+    };
+  }
+
+  private sortRecords(records: PublicationRecord[]): PublicationRecord[] {
+    return [...records].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
   }
 }

+ 252 - 17
src/app/services/viral-analysis.service.spec.ts

@@ -1,24 +1,203 @@
+import { firstValueFrom, of, throwError } from 'rxjs';
 import { ViralAnalysis } from '../models/douyin-insight.model';
+import { StorageGovernanceService } from './storage-governance.service';
 import { ViralAnalysisService } from './viral-analysis.service';
-import { firstValueFrom, of, throwError } from 'rxjs';
+
+const LEGACY_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
+const CACHE_STORAGE_KEY = 'videoWorkflow.viralAnalyses.cache.v1';
 
 describe('ViralAnalysisService', () => {
-  it('finds the latest saved analysis for a source video', () => {
-    const service = new ViralAnalysisService(
-      { currentUser: null } as any,
-      {} as any,
-      {} as any,
-    );
-    const older = analysis('older', '2026-06-03T00:00:00.000Z', '旧逐字稿');
-    const latest = analysis('latest', '2026-06-04T00:00:00.000Z', '真实来源视频逐字稿');
-    localStorage.setItem('videoWorkflow.viralAnalyses.items', JSON.stringify([older, latest]));
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+  });
+
+  it('finds the latest saved analysis for a source video from legacy data', () => {
+    const service = createService();
+    const older = analysis('older', 'local-user', '2026-06-03T00:00:00.000Z', '旧逐字稿');
+    const latest = analysis('latest', 'local-user', '2026-06-04T00:00:00.000Z', '真实来源视频逐字稿');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([older, latest]));
 
     expect(service.findLatestByAwemeId('video-1')?.transcript).toBe('真实来源视频逐字稿');
   });
 
+  it('keeps legacy key read-only and writes updates to governed cache', async () => {
+    const saved = analysis('viral-1', 'local-user', '2026-06-04T00:00:00.000Z', '逐字稿');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([saved]));
+    const service = createService();
+
+    const updated = service.markTopicsSaved('viral-1', ['topic-1']);
+    await Promise.resolve();
+
+    expect(updated.savedTopicIds).toEqual(['topic-1']);
+    expect(JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) || '[]')).toEqual([saved]);
+    expect(localStorage.getItem(CACHE_STORAGE_KEY)).toContain('__storageGovernance');
+  });
+
+  it('syncs saved analyses to Parse cloud entities for logged-in users', async () => {
+    const saved = analysis('viral-1', 'user-1', '2026-06-04T00:00:00.000Z', '逐字稿');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([saved]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(
+      { currentUser: { objectId: 'user-1' }, isLoggedIn: true },
+      {},
+      {},
+      cloud,
+    );
+
+    service.updateLocalAnalysis('viral-1', { savedTopicIds: ['topic-2'] });
+    await Promise.resolve();
+
+    expect(cloud.upsert).toHaveBeenCalledWith('viralAnalysis', 'viral-1', expect.objectContaining({
+      id: 'viral-1',
+      savedTopicIds: ['topic-2'],
+    }));
+  });
+
+  it('stores viral analysis comments, replies and transcript as separate Parse cloud entity rows', async () => {
+    const saved = analysisWithSamples('viral-rich', 'user-1', '2026-06-04T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([saved]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(
+      { currentUser: { objectId: 'user-1' }, isLoggedIn: true },
+      {},
+      {},
+      cloud,
+    );
+
+    service.updateLocalAnalysis('viral-rich', { savedTopicIds: ['topic-rich'] });
+    await Promise.resolve();
+
+    expect(cloud.upsert).toHaveBeenCalledWith('viralAnalysis', 'viral-rich', expect.objectContaining({
+      id: 'viral-rich',
+      commentsSnapshot: [],
+      repliesSnapshot: [],
+      transcript: undefined,
+      externalizedSamples: expect.objectContaining({
+        commentIds: ['viral-rich__comment__comment-1'],
+        replyIds: ['viral-rich__reply__reply-1'],
+        transcriptId: 'viral-rich__transcript',
+      }),
+    }));
+    expect(cloud.upsert).toHaveBeenCalledWith('viralAnalysis.comment', 'viral-rich__comment__comment-1', expect.objectContaining({
+      id: 'viral-rich__comment__comment-1',
+      sourceId: 'comment-1',
+      analysisId: 'viral-rich',
+      text: '这个方法第一步怎么做?',
+    }));
+    expect(cloud.upsert).toHaveBeenCalledWith('viralAnalysis.reply', 'viral-rich__reply__reply-1', expect.objectContaining({
+      id: 'viral-rich__reply__reply-1',
+      sourceId: 'reply-1',
+      analysisId: 'viral-rich',
+      text: '先判断账号定位。',
+    }));
+    expect(cloud.upsert).toHaveBeenCalledWith('viralAnalysis.transcript', 'viral-rich__transcript', expect.objectContaining({
+      id: 'viral-rich__transcript',
+      analysisId: 'viral-rich',
+      text: saved.transcript,
+    }));
+  });
+
+  it('rehydrates viral analyses from separate sample Parse cloud entity rows', async () => {
+    const full = analysisWithSamples('viral-cloud-rich', 'user-1', '2026-06-06T00:00:00.000Z');
+    const compact = {
+      ...full,
+      commentsSnapshot: [],
+      repliesSnapshot: [],
+      transcript: undefined,
+      externalizedSamples: {
+        commentIds: ['viral-cloud-rich__comment__comment-1'],
+        replyIds: ['viral-cloud-rich__reply__reply-1'],
+        transcriptId: 'viral-cloud-rich__transcript',
+      },
+    };
+    const rows: Record<string, any[]> = {
+      viralAnalysis: [{ data: compact }],
+      'viralAnalysis.comment': [{ data: {
+        ...full.commentsSnapshot[0],
+        id: 'viral-cloud-rich__comment__comment-1',
+        sourceId: 'comment-1',
+        analysisId: full.id,
+        awemeId: full.awemeId,
+        userId: full.userId,
+      } }],
+      'viralAnalysis.reply': [{ data: {
+        ...full.repliesSnapshot?.[0],
+        id: 'viral-cloud-rich__reply__reply-1',
+        sourceId: 'reply-1',
+        analysisId: full.id,
+        awemeId: full.awemeId,
+        userId: full.userId,
+      } }],
+      'viralAnalysis.transcript': [{ data: {
+        id: 'viral-cloud-rich__transcript',
+        analysisId: full.id,
+        awemeId: full.awemeId,
+        userId: full.userId,
+        text: full.transcript,
+        source: full.transcriptSource,
+        createdAt: full.createdAt,
+        updatedAt: full.updatedAt,
+      } }],
+    };
+    const cloud = {
+      list: vi.fn((entityType: string) => Promise.resolve(rows[entityType] || [])),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(
+      { currentUser: { objectId: 'user-1' }, isLoggedIn: true },
+      {},
+      {},
+      cloud,
+    );
+
+    const refreshed = await service.refreshAnalysesFromCloud();
+    const item = refreshed[0];
+
+    expect(item.id).toBe('viral-cloud-rich');
+    expect(item.commentsSnapshot.map((comment) => comment.id)).toEqual(['comment-1']);
+    expect(item.repliesSnapshot?.map((reply) => reply.id)).toEqual(['reply-1']);
+    expect(item.transcript).toBe(full.transcript);
+    expect((item as any).externalizedSamples).toBeUndefined();
+  });
+
+  it('refreshes analyses from Parse cloud entities and keeps newer local records', async () => {
+    const localNewer = analysis('viral-same', 'user-1', '2026-06-05T00:00:00.000Z', '本地较新');
+    const cloudOlder = analysis('viral-same', 'user-1', '2026-06-04T00:00:00.000Z', '云端较旧');
+    const cloudOnly = analysis('viral-cloud', 'user-1', '2026-06-06T00:00:00.000Z', '云端记录');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([localNewer]));
+    const cloud = {
+      list: vi.fn().mockResolvedValue([{ data: cloudOlder }, { data: cloudOnly }]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(
+      { currentUser: { objectId: 'user-1' }, isLoggedIn: true },
+      {},
+      {},
+      cloud,
+    );
+
+    const refreshed = await service.refreshAnalysesFromCloud();
+
+    expect(cloud.list).toHaveBeenCalledWith('viralAnalysis', '', 500);
+    expect(refreshed.map((item) => item.id).sort()).toEqual(['viral-cloud', 'viral-same']);
+    expect(refreshed.find((item) => item.id === 'viral-same')?.transcript).toBe('本地较新');
+    expect(service.findLatestByAwemeId('video-1')?.id).toBe('viral-cloud');
+  });
+
   it('continues analysis when the optional comments upstream is unavailable', async () => {
-    const service = new ViralAnalysisService(
-      { currentUser: null } as any,
+    const cloud = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService(
+      { currentUser: null, isLoggedIn: false },
       {
         getVideoDetail: () => of({
           data: {
@@ -30,9 +209,9 @@ describe('ViralAnalysisService', () => {
           },
         }),
         getVideoComments: () => throwError(() => new Error('fetch failed')),
-      } as any,
+      },
       {
-        buildContent: (_video: any, comments: any[]) => ({
+        buildContent: (_video: unknown, comments: unknown[]) => ({
           summary: `评论样本 ${comments.length} 条`,
           hookType: '场景钩子',
           openingPattern: '',
@@ -46,20 +225,57 @@ describe('ViralAnalysisService', () => {
           riskNotes: comments.length ? [] : ['缺少评论/回复样本'],
           evidenceRefs: [],
         }),
-      } as any,
+      },
+      cloud,
     );
 
     const result = await firstValueFrom(service.analyzeVideo({ awemeId: 'video-2', source: 'manual' }));
 
     expect(result.analysis.summary).toBe('评论样本 0 条');
     expect(result.analysis.riskNotes).toContain('缺少评论/回复样本');
+    expect(cloud.upsert).not.toHaveBeenCalled();
   });
 });
 
-function analysis(id: string, updatedAt: string, transcript: string): ViralAnalysis {
+function createService(
+  authPatch: Partial<{ currentUser: { objectId: string } | null; isLoggedIn: boolean }> = {},
+  apiPatch: Record<string, unknown> = {},
+  evidencePatch: Record<string, unknown> = {},
+  cloudPatch: Record<string, unknown> = {},
+): ViralAnalysisService {
+  const auth = {
+    currentUser: null,
+    isLoggedIn: false,
+    ...authPatch,
+  };
+  const api = {
+    getVideoDetail: vi.fn(),
+    getVideoComments: vi.fn(),
+    getCommentReplies: vi.fn(),
+    ...apiPatch,
+  };
+  const evidence = {
+    buildContent: vi.fn(),
+    ...evidencePatch,
+  };
+  const cloud = {
+    list: vi.fn().mockResolvedValue([]),
+    upsert: vi.fn().mockResolvedValue({}),
+    ...cloudPatch,
+  };
+  return new ViralAnalysisService(
+    auth as any,
+    api as any,
+    evidence as any,
+    cloud as any,
+    new StorageGovernanceService(),
+  );
+}
+
+function analysis(id: string, userId: string, updatedAt: string, transcript: string): ViralAnalysis {
   return {
     id,
-    userId: 'local-user',
+    userId,
     awemeId: 'video-1',
     source: 'search',
     videoSnapshot: {
@@ -97,3 +313,22 @@ function analysis(id: string, updatedAt: string, transcript: string): ViralAnaly
     updatedAt,
   };
 }
+
+function analysisWithSamples(id: string, userId: string, updatedAt: string): ViralAnalysis {
+  return {
+    ...analysis(id, userId, updatedAt, '完整逐字稿:先判断账号定位,再决定选题和脚本。'),
+    commentsSnapshot: [{
+      id: 'comment-1',
+      text: '这个方法第一步怎么做?',
+      likeCount: 12,
+      replyCount: 1,
+    }],
+    repliesSnapshot: [{
+      id: 'reply-1',
+      commentId: 'comment-1',
+      text: '先判断账号定位。',
+      likeCount: 3,
+    }],
+    transcriptSource: 'detail',
+  };
+}

+ 287 - 6
src/app/services/viral-analysis.service.ts

@@ -11,15 +11,62 @@ import {
   ViralVideoSnapshot,
 } from '../models/douyin-insight.model';
 import { DouyinEvidenceAnalysisService } from './douyin-evidence-analysis.service';
-
-const VIRAL_ANALYSIS_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
+
+const LEGACY_VIRAL_ANALYSIS_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
+const VIRAL_ANALYSIS_CACHE_KEY = 'videoWorkflow.viralAnalyses.cache.v1';
+const VIRAL_ANALYSIS_ENTITY_TYPE = 'viralAnalysis';
+const VIRAL_ANALYSIS_COMMENT_ENTITY_TYPE = 'viralAnalysis.comment';
+const VIRAL_ANALYSIS_REPLY_ENTITY_TYPE = 'viralAnalysis.reply';
+const VIRAL_ANALYSIS_TRANSCRIPT_ENTITY_TYPE = 'viralAnalysis.transcript';
+
+type ViralAnalysisCompact = ViralAnalysis & {
+  externalizedSamples?: {
+    commentIds?: string[];
+    replyIds?: string[];
+    transcriptId?: string;
+  };
+};
+
+type ViralCommentEntity = ViralCommentSnapshot & {
+  id: string;
+  sourceId?: string;
+  analysisId: string;
+  awemeId: string;
+  userId: string;
+};
+
+type ViralReplyEntity = ViralReplySnapshot & {
+  id: string;
+  sourceId?: string;
+  analysisId: string;
+  awemeId: string;
+  userId: string;
+};
+
+interface ViralTranscriptEntity {
+  id: string;
+  analysisId: string;
+  awemeId: string;
+  userId: string;
+  text: string;
+  source?: ViralAnalysis['transcriptSource'];
+  transcriptJob?: ViralAnalysis['transcriptJob'];
+  createdAt: string;
+  updatedAt: string;
+}
 
 @Injectable({ providedIn: 'root' })
 export class ViralAnalysisService {
+  private analysisCache: ViralAnalysis[] | null = null;
+
   constructor(
     private auth: AuthCreditService,
     private douyinApi: DouyinApiService,
     private evidenceAnalysis: DouyinEvidenceAnalysisService,
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
   ) {}
 
   analyzeVideo(input: AnalyzeVideoInput): Observable<ViralAnalysis> {
@@ -71,7 +118,22 @@ export class ViralAnalysisService {
   }
 
   async refreshAnalysesFromCloud(): Promise<ViralAnalysis[]> {
-    return this.listAnalyses();
+    const userId = this.currentAnalysisUserId();
+    const cloudItems = await this.fetchCloudAnalyses(userId);
+    if (!cloudItems.length) return this.listAnalyses();
+
+    const allLocal = this.readAll();
+    const others = allLocal.filter((item) => item.userId !== userId);
+    const currentLocal = allLocal.filter((item) => item.userId === userId);
+    const merged = new Map(currentLocal.map((item) => [item.id, item]));
+    for (const cloudItem of cloudItems) {
+      const localItem = merged.get(cloudItem.id);
+      const keepLocal = localItem && Date.parse(localItem.updatedAt) > Date.parse(cloudItem.updatedAt);
+      merged.set(cloudItem.id, keepLocal ? localItem : cloudItem);
+    }
+    const current = this.sortAnalyses(Array.from(merged.values()));
+    this.writeAll([...others, ...current]);
+    return current;
   }
 
   markTopicsSaved(analysisId: string, topicIds: string[]): ViralAnalysis {
@@ -83,6 +145,7 @@ export class ViralAnalysisService {
     const updated = { ...items[index], savedTopicIds: nextIds, updatedAt: new Date().toISOString() };
     items[index] = updated;
     this.writeAll(items);
+    this.mirrorSystemUpsert(updated);
     return updated;
   }
 
@@ -94,6 +157,7 @@ export class ViralAnalysisService {
     const updated = { ...items[index], ...patch, id: analysisId, userId, updatedAt: new Date().toISOString() };
     items[index] = updated;
     this.writeAll(items);
+    this.mirrorSystemUpsert(updated);
     return updated;
   }
 
@@ -286,20 +350,237 @@ export class ViralAnalysisService {
     const items = this.readAll().filter((item) => item.id !== analysis.id);
     items.unshift(analysis);
     this.writeAll(items.slice(0, 300));
+    this.mirrorSystemUpsert(analysis);
     return analysis;
   }
 
   private readAll(): ViralAnalysis[] {
+    if (this.analysisCache) return this.analysisCache;
+    const governed = this.storageGovernance.readJson<ViralAnalysis[]>(VIRAL_ANALYSIS_CACHE_KEY);
+    if (Array.isArray(governed)) {
+      this.analysisCache = this.sortAnalyses(governed.filter((item) => !!item?.id));
+      return this.analysisCache;
+    }
     try {
-      const raw = localStorage.getItem(VIRAL_ANALYSIS_STORAGE_KEY);
-      return raw ? JSON.parse(raw) as ViralAnalysis[] : [];
+      const raw = localStorage.getItem(LEGACY_VIRAL_ANALYSIS_STORAGE_KEY);
+      const parsed = raw ? JSON.parse(raw) as ViralAnalysis[] : [];
+      this.analysisCache = Array.isArray(parsed) ? this.sortAnalyses(parsed.filter((item) => !!item?.id)) : [];
+      return this.analysisCache;
     } catch {
+      this.analysisCache = [];
       return [];
     }
   }
 
   private writeAll(items: ViralAnalysis[]): void {
-    localStorage.setItem(VIRAL_ANALYSIS_STORAGE_KEY, JSON.stringify(items));
+    this.analysisCache = this.sortAnalyses(items).slice(0, 300);
+    const compactCache = this.analysisCache.map((item) => this.compactAnalysisForStorage(item));
+    const result = this.storageGovernance.writeJson(VIRAL_ANALYSIS_CACHE_KEY, compactCache, 'cache');
+    if (!result.stored) {
+      console.warn('[ViralAnalysisService] 爆款分析缓存写入失败,已保留内存快照:', result.reason);
+    }
+  }
+
+  private async fetchCloudAnalyses(userId: string): Promise<ViralAnalysis[]> {
+    try {
+      const [analysisEntities, commentEntities, replyEntities, transcriptEntities] = await Promise.all([
+        this.cloudStorage.list<ViralAnalysisCompact>(VIRAL_ANALYSIS_ENTITY_TYPE, '', 500),
+        this.cloudStorage.list<ViralCommentEntity>(VIRAL_ANALYSIS_COMMENT_ENTITY_TYPE, '', 1000),
+        this.cloudStorage.list<ViralReplyEntity>(VIRAL_ANALYSIS_REPLY_ENTITY_TYPE, '', 1000),
+        this.cloudStorage.list<ViralTranscriptEntity>(VIRAL_ANALYSIS_TRANSCRIPT_ENTITY_TYPE, '', 500),
+      ]);
+      const comments = commentEntities.map((entity) => entity.data).filter((item): item is ViralCommentEntity => !!item?.analysisId);
+      const replies = replyEntities.map((entity) => entity.data).filter((item): item is ViralReplyEntity => !!item?.analysisId);
+      const transcripts = transcriptEntities.map((entity) => entity.data).filter((item): item is ViralTranscriptEntity => !!item?.analysisId);
+      return this.sortAnalyses(analysisEntities
+        .map((entity) => this.normalizeAnalysis(entity.data, userId))
+        .filter((item): item is ViralAnalysis => !!item)
+        .map((analysis) => this.rehydrateAnalysis(analysis, comments, replies, transcripts)));
+    } catch (err: any) {
+      console.warn('[ViralAnalysisService] Parse 云端爆款分析刷新失败,使用本地治理缓存:', err?.message || err);
+      return [];
+    }
+  }
+
+  private mirrorSystemUpsert(analysis: ViralAnalysis): void {
+    if (!this.auth.isLoggedIn) return;
+    this.cloudStorage.upsert(VIRAL_ANALYSIS_ENTITY_TYPE, analysis.id, this.compactAnalysisForStorage(analysis)).catch((err) => {
+      console.warn('[ViralAnalysisService] Parse 云端同步爆款分析失败,已保留本地缓存:', err?.message || err);
+    });
+    this.mirrorAnalysisSamples(analysis);
+  }
+
+  private normalizeAnalysis(value: unknown, userId: string): ViralAnalysis | null {
+    const row = value as Partial<ViralAnalysis> | null;
+    if (!row || !row.id || !row.awemeId || !row.videoSnapshot || !row.analysis) return null;
+    const now = new Date().toISOString();
+    return {
+      id: String(row.id),
+      userId: row.userId || userId,
+      awemeId: String(row.awemeId),
+      source: row.source || 'manual',
+      videoSnapshot: {
+        awemeId: String(row.videoSnapshot.awemeId || row.awemeId),
+        desc: String(row.videoSnapshot.desc || ''),
+        authorName: row.videoSnapshot.authorName || '',
+        authorId: row.videoSnapshot.authorId || '',
+        diggCount: Number(row.videoSnapshot.diggCount || 0),
+        commentCount: Number(row.videoSnapshot.commentCount || 0),
+        shareCount: Number(row.videoSnapshot.shareCount || 0),
+        playCount: Number(row.videoSnapshot.playCount || 0),
+        createTime: row.videoSnapshot.createTime,
+        coverUrl: row.videoSnapshot.coverUrl || '',
+      },
+      commentsSnapshot: Array.isArray(row.commentsSnapshot) ? row.commentsSnapshot : [],
+      repliesSnapshot: Array.isArray(row.repliesSnapshot) ? row.repliesSnapshot : [],
+      transcript: row.transcript,
+      transcriptSource: row.transcriptSource,
+      transcriptJob: row.transcriptJob,
+      confidence: row.confidence || 'low',
+      analysis: {
+        summary: row.analysis.summary || '',
+        hookType: row.analysis.hookType || '',
+        openingPattern: row.analysis.openingPattern || '',
+        contentRhythm: row.analysis.contentRhythm || '',
+        conflict: row.analysis.conflict || '',
+        proofPoint: row.analysis.proofPoint || '',
+        audienceEmotion: row.analysis.audienceEmotion || '',
+        commentTrigger: row.analysis.commentTrigger || '',
+        reusableFrame: row.analysis.reusableFrame || '',
+        reusableAngles: Array.isArray(row.analysis.reusableAngles) ? row.analysis.reusableAngles : [],
+        riskNotes: Array.isArray(row.analysis.riskNotes) ? row.analysis.riskNotes : [],
+        evidenceRefs: Array.isArray(row.analysis.evidenceRefs) ? row.analysis.evidenceRefs : [],
+      },
+      savedTopicIds: Array.isArray(row.savedTopicIds) ? row.savedTopicIds : [],
+      createdAt: row.createdAt || now,
+      updatedAt: row.updatedAt || row.createdAt || now,
+    };
+  }
+
+  private compactAnalysisForStorage(analysis: ViralAnalysis): ViralAnalysisCompact {
+    const transcriptId = analysis.transcript ? this.transcriptEntityId(analysis.id) : undefined;
+    return {
+      ...analysis,
+      commentsSnapshot: [],
+      repliesSnapshot: [],
+      transcript: undefined,
+      transcriptJob: undefined,
+      externalizedSamples: {
+        commentIds: (analysis.commentsSnapshot || []).map((item, index) => this.commentEntityId(analysis.id, item, index)),
+        replyIds: (analysis.repliesSnapshot || []).map((item, index) => this.replyEntityId(analysis.id, item, index)),
+        transcriptId,
+      },
+    };
+  }
+
+  private mirrorAnalysisSamples(analysis: ViralAnalysis): void {
+    for (const [index, comment] of (analysis.commentsSnapshot || []).entries()) {
+      const id = this.commentEntityId(analysis.id, comment, index);
+      const entity: ViralCommentEntity = {
+        ...comment,
+        id,
+        sourceId: comment.id,
+        analysisId: analysis.id,
+        awemeId: analysis.awemeId,
+        userId: analysis.userId,
+      };
+      this.cloudStorage.upsert(VIRAL_ANALYSIS_COMMENT_ENTITY_TYPE, id, entity).catch((err) => {
+        console.warn('[ViralAnalysisService] Parse 云端同步爆款评论样本失败:', err?.message || err);
+      });
+    }
+    for (const [index, reply] of (analysis.repliesSnapshot || []).entries()) {
+      const id = this.replyEntityId(analysis.id, reply, index);
+      const entity: ViralReplyEntity = {
+        ...reply,
+        id,
+        sourceId: reply.id,
+        analysisId: analysis.id,
+        awemeId: analysis.awemeId,
+        userId: analysis.userId,
+      };
+      this.cloudStorage.upsert(VIRAL_ANALYSIS_REPLY_ENTITY_TYPE, id, entity).catch((err) => {
+        console.warn('[ViralAnalysisService] Parse 云端同步爆款回复样本失败:', err?.message || err);
+      });
+    }
+    if (analysis.transcript) {
+      const id = this.transcriptEntityId(analysis.id);
+      const entity: ViralTranscriptEntity = {
+        id,
+        analysisId: analysis.id,
+        awemeId: analysis.awemeId,
+        userId: analysis.userId,
+        text: analysis.transcript,
+        source: analysis.transcriptSource,
+        transcriptJob: analysis.transcriptJob,
+        createdAt: analysis.createdAt,
+        updatedAt: analysis.updatedAt,
+      };
+      this.cloudStorage.upsert(VIRAL_ANALYSIS_TRANSCRIPT_ENTITY_TYPE, id, entity).catch((err) => {
+        console.warn('[ViralAnalysisService] Parse 云端同步爆款逐字稿失败:', err?.message || err);
+      });
+    }
+  }
+
+  private rehydrateAnalysis(
+    analysis: ViralAnalysis,
+    comments: ViralCommentEntity[],
+    replies: ViralReplyEntity[],
+    transcripts: ViralTranscriptEntity[],
+  ): ViralAnalysis {
+    const row = analysis as ViralAnalysisCompact;
+    const refs = row.externalizedSamples;
+    const matchedCommentEntities = comments.filter((item) => item.analysisId === analysis.id);
+    const matchedReplyEntities = replies.filter((item) => item.analysisId === analysis.id);
+    const transcript = transcripts.find((item) => item.analysisId === analysis.id && (!refs?.transcriptId || item.id === refs.transcriptId));
+    const next: ViralAnalysis = {
+      ...analysis,
+      commentsSnapshot: matchedCommentEntities.length
+        ? this.orderSamples(matchedCommentEntities, refs?.commentIds).map((item) => this.stripCommentEntity(item))
+        : analysis.commentsSnapshot,
+      repliesSnapshot: matchedReplyEntities.length
+        ? this.orderSamples(matchedReplyEntities, refs?.replyIds).map((item) => this.stripReplyEntity(item))
+        : analysis.repliesSnapshot,
+      transcript: transcript?.text || analysis.transcript,
+      transcriptSource: transcript?.source || analysis.transcriptSource,
+      transcriptJob: transcript?.transcriptJob || analysis.transcriptJob,
+    };
+    delete (next as ViralAnalysisCompact).externalizedSamples;
+    return next;
+  }
+
+  private stripCommentEntity(entity: ViralCommentEntity): ViralCommentSnapshot {
+    const { analysisId: _analysisId, awemeId: _awemeId, userId: _userId, sourceId, ...comment } = entity;
+    return { ...comment, id: sourceId || comment.id };
+  }
+
+  private stripReplyEntity(entity: ViralReplyEntity): ViralReplySnapshot {
+    const { analysisId: _analysisId, awemeId: _awemeId, userId: _userId, sourceId, ...reply } = entity;
+    return { ...reply, id: sourceId || reply.id };
+  }
+
+  private orderSamples<T extends { id?: string }>(items: T[], order?: string[]): T[] {
+    if (!order?.length) return items;
+    const byId = new Map(items.map((item, index) => [String(item.id || index), item]));
+    return [
+      ...(order.map((id) => byId.get(id)).filter(Boolean) as T[]),
+      ...items.filter((item, index) => !order.includes(String(item.id || index))),
+    ];
+  }
+
+  private commentEntityId(analysisId: string, comment: ViralCommentSnapshot, index: number): string {
+    return `${analysisId}__comment__${comment.id || index}`;
+  }
+
+  private replyEntityId(analysisId: string, reply: ViralReplySnapshot, index: number): string {
+    return `${analysisId}__reply__${reply.id || index}`;
+  }
+
+  private transcriptEntityId(analysisId: string): string {
+    return `${analysisId}__transcript`;
+  }
+
+  private sortAnalyses(items: ViralAnalysis[]): ViralAnalysis[] {
+    return [...items].sort((a, b) => Date.parse(b.updatedAt || '') - Date.parse(a.updatedAt || ''));
   }
 
   private parseJson(value: any): any {

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio