Files
ftb-project-management/docs/superpowers/plans/2026-07-08-business-analysis-agent.md

111 KiB
Raw Blame History

Business Analysis Agent Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the read-only Business Analysis Agent so /wenfan-xiaobao and product/project/version pages can answer business data questions with Insight Cards, ECharts-rendered charts, structured reports, clickable evidence, and read-only follow-ups.

Architecture: Shared contracts define the platform AnalysisPlan, MetricResult, Evidence, Report, FollowUp, and UnifiedChartSpec types. The backend adds an ai/analysis pipeline under the existing AiModule: Semantic Layer -> Analysis Strategy -> Analysis Plan Processor -> Permission Scope Resolver -> Metric Engine -> response builders. The frontend adds an Analysis API client, a renderer-agnostic ChartSpec-to-ECharts boundary, Apple Vision style analysis components, and surfaces in the AI Assistant plus context pages.

Tech Stack: TypeScript, NestJS, Prisma, PostgreSQL, class-validator, Jest, Next.js App Router, React, Tailwind CSS, Zustand-backed auth/session headers, ECharts via echarts and echarts-for-react, Node test runner for frontend lib tests.

Global Constraints

  • Business Analysis Agent is read-only and must not mutate Product, Project, Version, Requirement, VersionPlan, DevTask, TestCase, Bug, Member, WorkActivity, TaskWorklog, Overtime, or Xiaobao data.
  • AI must not write SQL and must not directly query the database.
  • AI Planning may only propose an AnalysisPlan; AnalysisPlanProcessor must validate and normalize before execution.
  • Analysis queries may only read data already allowed by the current user's permissions and page context.
  • ChartSpec is a platform contract, not an ECharts option object.
  • Frontend renderer uses ECharts first, behind a single ChartRenderer boundary.
  • User-visible confidence is level-based: high / medium / low for semantic confidence and sufficient / partial / insufficient for data confidence.
  • Ranking plans must include a Top N limit; default Top 10, maximum Top 20.
  • Default last 30 days applies only to unspecified trend, throughput, efficiency, effort, and change questions.
  • Current state, risk, and lifecycle questions must not be forced into last 30 days.
  • No Data Strategy must return a structured response and must not invent analysis text.
  • First-version follow-ups are read-only: question, drilldown, and export.
  • AI Analysis Design System uses Apple Vision style: large whitespace, large radius, light shadow, translucent material, restrained color, number-first cards, smooth line-area charts, rounded horizontal bars, minimal axes, and no BI big-screen styling.
  • New business analysis data must not add an AppData key.

File Structure

Shared Contract

  • Create packages/shared/src/analysis.ts: all analysis contracts shared by server and web.
  • Modify packages/shared/src/index.ts: export analysis.

Backend

  • Create apps/server/src/modules/ai/dto/analysis.dto.ts: request DTO for POST /api/v1/ai/analysis.
  • Create apps/server/src/modules/ai/prompts/analysis-plan.ts: tool prompt/schema for AI Planning Strategy.
  • Create apps/server/src/modules/ai/prompts/analysis-report.ts: tool prompt/schema for optional AI report text over deterministic Metric Result.
  • Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts: phrase-to-semantic concept mapping and confidence.
  • Create apps/server/src/modules/ai/analysis/metric-catalog.ts: metric catalog and helpers.
  • Create apps/server/src/modules/ai/analysis/analysis-strategy.ts: Template Strategy, deterministic Rule Composition Strategy, AI Planning fallback boundary.
  • Create apps/server/src/modules/ai/analysis/analysis-plan-processor.ts: validate and normalize.
  • Create apps/server/src/modules/ai/analysis/permission-scope-resolver.ts: resolves DataScope from current user, permissions, and context.
  • Create apps/server/src/modules/ai/analysis/metric-engine.ts: executes approved MVP metrics from relation tables.
  • Create apps/server/src/modules/ai/analysis/chart-spec-builder.ts: converts Metric Result to Unified ChartSpec.
  • Create apps/server/src/modules/ai/analysis/insight-engine.ts: deterministic Insight Card generation.
  • Create apps/server/src/modules/ai/analysis/report-builder.ts: deterministic report fallback and AI-enhanced report integration.
  • Create apps/server/src/modules/ai/analysis/follow-up-builder.ts: read-only follow-up generation.
  • Create apps/server/src/modules/ai/analysis/business-analysis.service.ts: orchestrates the pipeline.
  • Modify apps/server/src/modules/ai/ai.controller.ts: add POST /analysis.
  • Modify apps/server/src/modules/ai/ai.module.ts: import CommonDomainModule, provide analysis services.
  • Test files:
    • Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts
    • Create apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
    • Create apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts
    • Create apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts
    • Create apps/server/src/modules/ai/analysis/metric-engine.spec.ts
    • Create apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts
    • Modify apps/server/src/modules/ai/ai.service.spec.ts only if report/planning helpers require existing provider behavior coverage.

Frontend

  • Modify apps/web/package.json: add echarts and echarts-for-react.
  • Lockfile update: pnpm-lock.yaml via pnpm add --filter web echarts echarts-for-react.
  • Create apps/web/lib/analysis-api.ts: typed API client for /ai/analysis.
  • Create apps/web/lib/analysis-chart-renderer.ts: pure UnifiedChartSpec -> EChartsOption conversion.
  • Create apps/web/lib/analysis-chart-renderer.test.ts: verifies renderer boundary and Apple Vision options.
  • Create apps/web/components/analysis/InsightCard.tsx
  • Create apps/web/components/analysis/AnalysisChart.tsx
  • Create apps/web/components/analysis/AnalysisReport.tsx
  • Create apps/web/components/analysis/EvidenceList.tsx
  • Create apps/web/components/analysis/FollowUpActions.tsx
  • Create apps/web/components/analysis/AnalysisResultBlock.tsx
  • Create apps/web/components/analysis/AnalysisEntryButton.tsx
  • Create apps/web/components/analysis/AnalysisContextDrawer.tsx
  • Modify apps/web/app/wenfan-xiaobao/page.tsx: add business-analysis message type and call analysis-api.
  • Modify apps/web/app/products/[id]/page.tsx: add product context analysis entry.
  • Modify apps/web/app/projects/[id]/page.tsx: add project context analysis entry.
  • Modify apps/web/app/versions/[id]/page.tsx: add version context analysis entry.
  • Modify apps/web/lib/api.test.ts: cover /ai/analysis headers if analysis-api uses api.postRaw.
  • Create apps/web/lib/analysis-api.test.ts: response handling and no-data/no-permission branches.

Documentation

  • Modify docs/agent-spec.md: add implementation status once feature lands.
  • Modify docs/roadmap.md: move V3.4 from design-confirmed to implemented when all verification passes.
  • Modify docs/workflow.md: add Business Analysis Agent operational notes if implementation changes the planned workflow.

Task 1: Shared Analysis Contracts

Files:

  • Create: packages/shared/src/analysis.ts
  • Modify: packages/shared/src/index.ts
  • Test: packages/shared/src/analysis.ts through pnpm --filter @ftb/shared type-check

Interfaces:

  • Consumes: the spec contract in docs/superpowers/specs/2026-07-08-business-analysis-agent-design.md.

  • Produces:

    • AnalysisRequest
    • AnalysisResponse
    • AnalysisPlan
    • MetricDefinition
    • MetricResult
    • UnifiedChartSpec
    • InsightCard
    • AnalysisReport
    • EvidenceItem
    • FollowUp
  • Step 1: Write the shared contract file

Create packages/shared/src/analysis.ts with these exported types:

export type AnalysisSurface = 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';

export type MetricId =
  | 'version_risk_score'
  | 'completion_trend'
  | 'overdue_item_count'
  | 'requirement_status_count'
  | 'requirement_completion_count'
  | 'requirement_source_count'
  | 'department_workload'
  | 'member_pending_work'
  | 'member_effort_hours'
  | 'bug_severity_count'
  | 'test_pass_rate'
  | 'overtime_reason_hours'
  | 'delay_rate'
  | 'delay_reason_count';

export type DimensionId =
  | 'product'
  | 'project'
  | 'version'
  | 'requirement_status'
  | 'requirement_type'
  | 'requirement_source'
  | 'department'
  | 'member'
  | 'role'
  | 'month'
  | 'week'
  | 'day'
  | 'bug_severity'
  | 'bug_status'
  | 'test_status'
  | 'delay_reason';

export type AnalysisType =
  | 'ranking'
  | 'trend'
  | 'comparison'
  | 'distribution'
  | 'composition'
  | 'correlation'
  | 'breakdown'
  | 'summary';

export type TimePolicy = 'current_state' | 'last_30_days' | 'lifecycle' | 'user_required' | 'explicit_range';

export type ChartKind =
  | 'number_card'
  | 'line_area'
  | 'horizontal_bar'
  | 'stacked_horizontal_bar'
  | 'donut'
  | 'table_preview';

export interface MetricRef {
  metricId: MetricId;
  version: number;
}

export interface MetricDefinition {
  metricId: MetricId;
  version: number;
  name: string;
  description: string;
  formula: string;
  owner: string;
  supportedDimensions: DimensionId[];
  supportedAnalysisTypes: AnalysisType[];
  defaultChart: ChartKind;
  defaultDimension?: DimensionId;
  defaultTimePolicy: TimePolicy;
  status: 'active' | 'deprecated';
}

export type DataScope =
  | { type: 'self'; userId: string }
  | { type: 'managed_projects'; projectIds: string[] }
  | { type: 'product'; productId: string }
  | { type: 'project'; projectId: string }
  | { type: 'version'; versionId: string }
  | { type: 'system'; reason: 'admin' | 'management_permission' };

export interface AnalysisPlan {
  metricRef: MetricRef;
  analysisType: AnalysisType;
  dimensions: DimensionId[];
  timeRange?: {
    start: string;
    end: string;
    policy: TimePolicy;
  };
  filters: Record<string, string | number | boolean | string[] | number[]>;
  scope: DataScope;
  limit?: number;
  sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}

export interface EvidenceItem {
  label: string;
  value: string | number;
  unit?: string;
  sourceDomain:
    | 'product'
    | 'project'
    | 'version'
    | 'requirement'
    | 'version_plan'
    | 'dev_task'
    | 'test_case'
    | 'bug'
    | 'work_activity'
    | 'task_worklog'
    | 'overtime'
    | 'xiaobao';
  sourceLabel?: string;
  drilldown?: {
    type: 'list' | 'detail';
    target: string;
    filters: Record<string, unknown>;
  };
}

export interface MetricResult {
  metricRef: MetricRef;
  analysisType: AnalysisType;
  columns: Array<{ id: string; label: string; type: 'string' | 'number' | 'date' | 'percent' }>;
  rows: Array<Record<string, string | number | null>>;
  totals?: Record<string, string | number>;
  comparison?: {
    baselineLabel: string;
    currentLabel: string;
    deltaValue?: number;
    deltaPercent?: number;
  };
  evidence: EvidenceItem[];
  dataScope: DataScope;
  generatedAt: string;
}

export interface UnifiedChartSpec {
  kind: ChartKind;
  title: string;
  subtitle?: string;
  dataset: {
    source: Array<Record<string, string | number | null>>;
    x?: string;
    y?: string;
    series?: string;
    value?: string;
    label?: string;
  };
  encoding: {
    x?: { field: string; label: string };
    y?: { field: string; label: string };
    value?: { field: string; label: string; unit?: string };
    color?: { mode: 'single' | 'risk' | 'semantic'; field?: string };
  };
  annotations?: Array<{
    type: 'outlier' | 'peak' | 'target' | 'threshold';
    label: string;
    value?: string | number;
    field?: string;
  }>;
  stylePreset: 'apple_vision_light' | 'apple_vision_dark';
}

export interface InsightCard {
  summary: string;
  primaryValue?: {
    label: string;
    value: string | number;
    unit?: string;
  };
  comparison?: {
    label: string;
    direction: 'up' | 'down' | 'flat';
    value: string | number;
    tone: 'positive' | 'negative' | 'neutral';
  };
  semanticConfidence: 'high' | 'medium' | 'low';
  dataConfidence: 'sufficient' | 'partial' | 'insufficient';
}

export interface AnalysisReport {
  summary: string;
  keyFindings: string[];
  evidence: EvidenceItem[];
  suggestions: string[];
  dataScope: {
    timeDescription: string;
    permissionDescription: string;
    metricFormulaDescription: string;
    generatedAt: string;
  };
}

export type FollowUp =
  | { type: 'question'; label: string; prompt: string }
  | { type: 'drilldown'; label: string; target: string; filters: Record<string, unknown> }
  | { type: 'export'; label: string; format: 'png' | 'pdf' | 'csv' };

export interface AnalysisRequest {
  question: string;
  context?: {
    surface: AnalysisSurface;
    productId?: string;
    projectId?: string;
    versionId?: string;
  };
}

export type AnalysisErrorCode =
  | 'NO_PERMISSION'
  | 'UNSUPPORTED_ANALYSIS'
  | 'AMBIGUOUS_INTENT'
  | 'NO_DATA'
  | 'AI_UNAVAILABLE'
  | 'INVALID_PLAN';

export type AnalysisResponse =
  | {
      ok: true;
      plan: AnalysisPlan;
      metricResult: MetricResult;
      insight: InsightCard;
      chart: UnifiedChartSpec;
      report: AnalysisReport;
      followUps: FollowUp[];
    }
  | {
      ok: false;
      code: AnalysisErrorCode;
      message: string;
      clarificationOptions?: Array<{ label: string; prompt: string }>;
      dataScope?: DataScope;
    };
  • Step 2: Export the shared contract

Modify packages/shared/src/index.ts:

export * from './enums';
export * from './types';
export * from './agent';
export * from './analysis';
  • Step 3: Run shared type-check

Run: pnpm --filter @ftb/shared type-check

Expected: command exits 0.

  • Step 4: Commit
git add packages/shared/src/analysis.ts packages/shared/src/index.ts
git commit -m "feat(ai-analysis): 添加业务分析共享契约"

Task 2: Semantic Layer, Metric Catalog, and Plan Processor

Files:

  • Create: apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts
  • Create: apps/server/src/modules/ai/analysis/metric-catalog.ts
  • Create: apps/server/src/modules/ai/analysis/analysis-plan-processor.ts
  • Create: apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts
  • Create: apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
  • Create: apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts

Interfaces:

  • Consumes from Task 1: AnalysisPlan, MetricDefinition, MetricId, DimensionId, AnalysisType, TimePolicy, DataScope.

  • Produces:

    • parseSemanticIntent(question: string): SemanticIntent
    • getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null
    • normalizeAnalysisPlan(input: AnalysisPlanDraft, now?: Date): AnalysisPlan
    • validateAnalysisPlan(plan: AnalysisPlan): void
  • Step 1: Write failing semantic tests

Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts:

import { parseSemanticIntent } from './analysis-semantic-layer';

describe('analysis semantic layer', () => {
  it('maps busy wording to workload with high confidence', () => {
    expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
      concept: 'workload',
      semanticConfidence: 'high',
      metricId: 'department_workload',
      analysisType: 'ranking',
    });
  });

  it('maps pressure wording to workload pressure with medium confidence', () => {
    expect(parseSemanticIntent('谁压力最大')).toMatchObject({
      concept: 'work_pressure',
      semanticConfidence: 'medium',
      metricId: 'member_pending_work',
      analysisType: 'ranking',
    });
  });

  it('keeps project risk as current-state release risk', () => {
    expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
      concept: 'release_risk',
      metricId: 'version_risk_score',
      timePolicy: 'current_state',
    });
  });
});
  • Step 2: Write failing catalog and processor tests

Create apps/server/src/modules/ai/analysis/metric-catalog.spec.ts:

import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';

describe('metric catalog', () => {
  it('defines versioned active metrics used by the MVP templates', () => {
    const metric = getMetricDefinition('version_risk_score');

    expect(metric).toMatchObject({
      metricId: 'version_risk_score',
      version: 1,
      status: 'active',
      defaultTimePolicy: 'current_state',
    });
  });

  it('does not expose raw chart renderer contracts', () => {
    const charts = listMetricDefinitions().map((item) => item.defaultChart);

    expect(charts).toContain('horizontal_bar');
    expect(charts).not.toContain('echarts_option');
  });
});

Create apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts:

import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';

describe('analysis plan processor', () => {
  it('normalizes trend plans without a time range to last 30 days', () => {
    const plan = normalizeAnalysisPlan({
      metricId: 'requirement_completion_count',
      analysisType: 'trend',
      dimensions: ['day'],
      scope: { type: 'self', userId: 'm-1' },
      filters: {},
    }, new Date('2026-07-08T12:00:00.000Z'));

    expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
    expect(plan.timeRange).toEqual({
      start: '2026-06-09T00:00:00.000Z',
      end: '2026-07-08T23:59:59.999Z',
      policy: 'last_30_days',
    });
  });

  it('keeps current-state risk plans without a time range', () => {
    const plan = normalizeAnalysisPlan({
      metricId: 'version_risk_score',
      analysisType: 'ranking',
      dimensions: ['version'],
      scope: { type: 'managed_projects', projectIds: ['project-1'] },
      filters: {},
    }, new Date('2026-07-08T12:00:00.000Z'));

    expect(plan.timeRange).toBeUndefined();
    expect(plan.limit).toBe(10);
  });

  it('rejects unsupported metric dimensions', () => {
    const plan = normalizeAnalysisPlan({
      metricId: 'bug_severity_count',
      analysisType: 'ranking',
      dimensions: ['department'],
      scope: { type: 'self', userId: 'm-1' },
      filters: {},
    });

    expect(() => validateAnalysisPlan(plan)).toThrow('Unsupported dimension department for metric bug_severity_count');
  });
});

Run: pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog

Expected before implementation: FAIL because files/functions do not exist.

  • Step 3: Implement semantic layer

Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts:

import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';

export type SemanticConcept =
  | 'workload'
  | 'work_pressure'
  | 'release_risk'
  | 'delay'
  | 'delivery_efficiency'
  | 'quality_risk'
  | 'requirement_completion'
  | 'unknown';

export interface SemanticIntent {
  concept: SemanticConcept;
  metricId: MetricId;
  analysisType: AnalysisType;
  timePolicy: TimePolicy;
  semanticConfidence: 'high' | 'medium' | 'low';
}

export function parseSemanticIntent(question: string): SemanticIntent {
  const text = question.trim().toLowerCase();
  if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
    return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
  }
  if (/(压力|压着|吃紧)/.test(text)) {
    return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
  }
  if (/(忙|负载|待办|任务最多)/.test(text)) {
    const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text) ? 'department_workload' : 'member_pending_work';
    return intent('workload', metricId, 'ranking', 'current_state', 'high');
  }
  if (/(延期|逾期|超期)/.test(text)) {
    return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
  }
  if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
    return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
  }
  if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
    const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
    const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
    return intent('quality_risk', metricId, analysisType, metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state', 'high');
  }
  if (/(加班|投入|工时)/.test(text)) {
    return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
  }
  return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
}

function intent(
  concept: SemanticConcept,
  metricId: MetricId,
  analysisType: AnalysisType,
  timePolicy: TimePolicy,
  semanticConfidence: 'high' | 'medium' | 'low',
): SemanticIntent {
  return { concept, metricId, analysisType, timePolicy, semanticConfidence };
}
  • Step 4: Implement metric catalog

Create apps/server/src/modules/ai/analysis/metric-catalog.ts with one active version: 1 definition for every MVP metric:

import type { MetricDefinition, MetricId } from '@ftb/shared';

export const METRIC_CATALOG: MetricDefinition[] = [
  {
    metricId: 'version_risk_score',
    version: 1,
    name: '版本风险分',
    description: '基于小宝风险摘要的当前版本风险排行。',
    formula: 'xiaobao_risk_summaries.risk_score',
    owner: 'xiaobao',
    supportedDimensions: ['version', 'project', 'product'],
    supportedAnalysisTypes: ['ranking', 'summary'],
    defaultChart: 'horizontal_bar',
    defaultDimension: 'version',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'completion_trend',
    version: 1,
    name: '完成趋势',
    description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
    formula: 'count(completed_at or terminal status updated_at) by day',
    owner: 'analysis',
    supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
    supportedAnalysisTypes: ['trend', 'comparison'],
    defaultChart: 'line_area',
    defaultDimension: 'day',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'overdue_item_count',
    version: 1,
    name: '逾期事项数',
    description: '当前超过计划结束时间且未完成的事项数量。',
    formula: 'count(open items where due_at < now)',
    owner: 'analysis',
    supportedDimensions: ['version', 'project', 'member', 'department'],
    supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
    defaultChart: 'horizontal_bar',
    defaultDimension: 'version',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'requirement_status_count',
    version: 1,
    name: '需求状态分布',
    description: '按需求状态统计需求数量。',
    formula: 'count(requirements) by status',
    owner: 'requirement',
    supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
    supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
    defaultChart: 'donut',
    defaultDimension: 'requirement_status',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'requirement_completion_count',
    version: 1,
    name: '需求完成数量',
    description: '按时间统计进入 released 或 closed 的需求数量。',
    formula: "count(requirements where status in ('released','closed')) by time bucket",
    owner: 'requirement',
    supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
    supportedAnalysisTypes: ['trend', 'comparison'],
    defaultChart: 'line_area',
    defaultDimension: 'day',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'requirement_source_count',
    version: 1,
    name: '需求来源/类型占比',
    description: '按需求来源或类型统计需求数量。',
    formula: 'count(requirements) by source_type or type',
    owner: 'requirement',
    supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
    supportedAnalysisTypes: ['composition', 'distribution'],
    defaultChart: 'donut',
    defaultDimension: 'requirement_source',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'department_workload',
    version: 1,
    name: '部门负载',
    description: '按部门统计当前未完成事项数量。',
    formula: 'count(open work items grouped by user.department_id)',
    owner: 'management',
    supportedDimensions: ['department'],
    supportedAnalysisTypes: ['ranking', 'breakdown'],
    defaultChart: 'horizontal_bar',
    defaultDimension: 'department',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'member_pending_work',
    version: 1,
    name: '成员待办',
    description: '按成员统计当前未完成事项数量。',
    formula: 'count(open work items grouped by assignee or owner)',
    owner: 'management',
    supportedDimensions: ['member', 'role', 'project', 'version'],
    supportedAnalysisTypes: ['ranking', 'breakdown'],
    defaultChart: 'horizontal_bar',
    defaultDimension: 'member',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'member_effort_hours',
    version: 1,
    name: '成员投入工时',
    description: '按成员统计工作活动、工时记录和加班投入。',
    formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
    owner: 'management',
    supportedDimensions: ['member', 'department', 'project', 'version'],
    supportedAnalysisTypes: ['ranking', 'comparison'],
    defaultChart: 'horizontal_bar',
    defaultDimension: 'member',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'bug_severity_count',
    version: 1,
    name: 'Bug 严重度分布',
    description: '按严重度统计未关闭 Bug 数量。',
    formula: "count(bugs where status not in ('closed','rejected')) by severity",
    owner: 'quality',
    supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
    supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
    defaultChart: 'stacked_horizontal_bar',
    defaultDimension: 'bug_severity',
    defaultTimePolicy: 'current_state',
    status: 'active',
  },
  {
    metricId: 'test_pass_rate',
    version: 1,
    name: '测试通过率',
    description: '按时间统计测试通过用例占已执行用例比例。',
    formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
    owner: 'quality',
    supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
    supportedAnalysisTypes: ['trend', 'comparison'],
    defaultChart: 'line_area',
    defaultDimension: 'day',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'overtime_reason_hours',
    version: 1,
    name: '加班原因工时',
    description: '按加班原因统计加班时长。',
    formula: 'sum(overtime_records.hours) by reason',
    owner: 'management',
    supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
    supportedAnalysisTypes: ['composition', 'ranking'],
    defaultChart: 'donut',
    defaultDimension: 'delay_reason',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'delay_rate',
    version: 1,
    name: '延期率',
    description: '按范围统计逾期事项占全部计划事项比例。',
    formula: 'overdue_count / planned_item_count',
    owner: 'analysis',
    supportedDimensions: ['month', 'project', 'version', 'department'],
    supportedAnalysisTypes: ['trend', 'comparison'],
    defaultChart: 'line_area',
    defaultDimension: 'month',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
  {
    metricId: 'delay_reason_count',
    version: 1,
    name: '延期原因数量',
    description: '按原因统计延期相关需求变更或加班原因。',
    formula: 'count(requirement.change_reason) + count(overtime.reason)',
    owner: 'analysis',
    supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
    supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
    defaultChart: 'line_area',
    defaultDimension: 'delay_reason',
    defaultTimePolicy: 'last_30_days',
    status: 'active',
  },
];

export function listMetricDefinitions(): MetricDefinition[] {
  return METRIC_CATALOG.slice();
}

export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
  const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
  if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
  return candidates.find((item) => item.status === 'active') ?? null;
}
  • Step 5: Implement plan processor

Create apps/server/src/modules/ai/analysis/analysis-plan-processor.ts with these exports:

import { BadRequestException } from '@nestjs/common';
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';

export interface AnalysisPlanDraft {
  metricId: MetricId;
  metricVersion?: number;
  analysisType: AnalysisType;
  dimensions: DimensionId[];
  scope: DataScope;
  filters?: Record<string, string | number | boolean | string[] | number[]>;
  timeRange?: { start: string; end: string; policy?: TimePolicy };
  limit?: number;
  sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}

const MAX_TOP_N = 20;
const DEFAULT_TOP_N = 10;

export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
  const metric = getMetricDefinition(input.metricId, input.metricVersion);
  if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);

  const analysisType = input.analysisType;
  const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
  const timeRange = input.timeRange
    ? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
    : needsDefaultTime
      ? last30Days(now)
      : undefined;

  const plan: AnalysisPlan = {
    metricRef: { metricId: metric.metricId, version: metric.version },
    analysisType,
    dimensions: input.dimensions.length > 0 ? input.dimensions : metric.defaultDimension ? [metric.defaultDimension] : [],
    filters: input.filters ?? {},
    scope: input.scope,
    ...(timeRange ? { timeRange } : {}),
    limit: normalizeLimit(input.limit, analysisType),
    ...(input.sort ? { sort: input.sort } : {}),
  };
  validateAnalysisPlan(plan);
  return plan;
}

export function validateAnalysisPlan(plan: AnalysisPlan): void {
  const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
  if (!metric || metric.status !== 'active') {
    throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
  }
  if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
    throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
  }
  for (const dimension of plan.dimensions) {
    if (!metric.supportedDimensions.includes(dimension)) {
      throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
    }
  }
  if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
    throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
  }
  if (plan.timeRange) {
    const start = Date.parse(plan.timeRange.start);
    const end = Date.parse(plan.timeRange.end);
    if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
      throw new BadRequestException('Invalid time range');
    }
  }
}

function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
  if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
  const value = limit ?? DEFAULT_TOP_N;
  return Math.min(Math.max(1, value), MAX_TOP_N);
}

function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
  const end = new Date(now);
  end.setHours(23, 59, 59, 999);
  const start = new Date(end);
  start.setDate(start.getDate() - 29);
  start.setHours(0, 0, 0, 0);
  return {
    start: start.toISOString(),
    end: end.toISOString(),
    policy: 'last_30_days',
  };
}
  • Step 6: Run focused backend tests

Run: pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog

Expected: PASS for the three new test files.

  • Step 7: Commit
git add apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts \
  apps/server/src/modules/ai/analysis/metric-catalog.ts \
  apps/server/src/modules/ai/analysis/analysis-plan-processor.ts \
  apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts \
  apps/server/src/modules/ai/analysis/metric-catalog.spec.ts \
  apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts
git commit -m "feat(ai-analysis): 添加语义层和分析计划处理器"

Task 3: Permission Scope Resolver

Files:

  • Create: apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
  • Create: apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts

Interfaces:

  • Consumes: DataScope from Task 1, RbacService from apps/server/src/common/rbac/rbac.service.ts, PrismaService.

  • Produces:

    • resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope>
    • buildScopedWhere(scope: DataScope): { productId?: any; projectId?: any; versionId?: any }
  • Step 1: Write failing resolver tests

Create apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts:

import { ForbiddenException } from '@nestjs/common';
import { PermissionScopeResolver } from './permission-scope-resolver';

describe('PermissionScopeResolver', () => {
  function makeResolver() {
    const prisma = {
      project: { findFirst: jest.fn() },
      version: { findFirst: jest.fn() },
      projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
    };
    const rbac = {
      assertGlobalPermission: jest.fn(),
      assertProjectRole: jest.fn(),
    };
    return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
  }

  it('returns system scope for wildcard permissions', async () => {
    const { rbac, resolver } = makeResolver();
    rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });

    await expect(resolver.resolveAnalysisScope({
      actorId: 'm-8',
      permissions: ['*'],
      context: { surface: 'ai_assistant' },
    })).resolves.toEqual({ type: 'system', reason: 'admin' });
  });

  it('returns managed project scope for management permission', async () => {
    const { prisma, rbac, resolver } = makeResolver();
    rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
    prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);

    await expect(resolver.resolveAnalysisScope({
      actorId: 'm-pm',
      permissions: ['management:view'],
      context: { surface: 'ai_assistant' },
    })).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
  });

  it('restricts version context to the requested version when the user has page access', async () => {
    const { prisma, rbac, resolver } = makeResolver();
    prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
    rbac.assertProjectRole.mockResolvedValue({ actorId: 'm-dev', projectId: 'project-1', role: 'member', via: 'project_member' });

    await expect(resolver.resolveAnalysisScope({
      actorId: 'm-dev',
      permissions: [],
      context: { surface: 'version_detail', versionId: 'version-1' },
    })).resolves.toEqual({ type: 'version', versionId: 'version-1' });
  });

  it('rejects missing actor id', async () => {
    const { resolver } = makeResolver();

    await expect(resolver.resolveAnalysisScope({
      actorId: '',
      permissions: [],
      context: { surface: 'ai_assistant' },
    })).rejects.toBeInstanceOf(ForbiddenException);
  });
});

Run: pnpm --filter server test -- permission-scope-resolver

Expected before implementation: FAIL because the resolver file does not exist.

  • Step 2: Implement resolver

Create apps/server/src/modules/ai/analysis/permission-scope-resolver.ts:

import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { AnalysisRequest, DataScope } from '@ftb/shared';
import { RbacService } from '../../../common/rbac/rbac.service';
import { PrismaService } from '../../../prisma/prisma.service';

export interface AnalysisScopeInput {
  actorId?: string;
  permissions: string[];
  context?: AnalysisRequest['context'];
}

@Injectable()
export class PermissionScopeResolver {
  constructor(
    private readonly prisma: PrismaService,
    private readonly rbac: RbacService,
  ) {}

  async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
    const actorId = input.actorId?.trim();
    if (!actorId) throw new ForbiddenException('Missing actor scope');

    const context = input.context;
    if (context?.surface === 'version_detail' && context.versionId) {
      const version = await this.prisma.version.findFirst({
        where: { id: context.versionId },
        select: { id: true, projectId: true },
      });
      if (!version) throw new NotFoundException('Version not found');
      if (version.projectId) {
        await this.rbac.assertProjectRole({
          actorId,
          projectId: version.projectId,
          allowedRoles: ['viewer'],
          permissions: input.permissions,
        });
      }
      return { type: 'version', versionId: version.id };
    }

    if (context?.surface === 'project_detail' && context.projectId) {
      await this.rbac.assertProjectRole({
        actorId,
        projectId: context.projectId,
        allowedRoles: ['viewer'],
        permissions: input.permissions,
      });
      return { type: 'project', projectId: context.projectId };
    }

    if (context?.surface === 'product_detail' && context.productId) {
      if (input.permissions.includes('*')) return { type: 'product', productId: context.productId };
      const project = await this.prisma.project.findFirst({
        where: { productId: context.productId, members: { some: { userId: actorId } } },
        select: { id: true },
      });
      if (!project && !input.permissions.includes('product:view')) {
        throw new ForbiddenException('No product analysis scope');
      }
      return { type: 'product', productId: context.productId };
    }

    if (input.permissions.includes('*')) {
      await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
      return { type: 'system', reason: 'admin' };
    }

    if (input.permissions.includes('management:view')) {
      await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
      const rows = await this.prisma.projectMember.findMany({
        where: { userId: actorId, role: { in: ['owner', 'admin'] } },
        select: { projectId: true },
      });
      return { type: 'managed_projects', projectIds: Array.from(new Set(rows.map((row) => row.projectId))) };
    }

    return { type: 'self', userId: actorId };
  }
}

export function buildScopedWhere(scope: DataScope) {
  if (scope.type === 'product') return { productId: scope.productId };
  if (scope.type === 'project') return { projectId: scope.projectId };
  if (scope.type === 'version') return { versionId: scope.versionId };
  if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
  return {};
}
  • Step 3: Run focused resolver tests

Run: pnpm --filter server test -- permission-scope-resolver

Expected: PASS.

  • Step 4: Commit
git add apps/server/src/modules/ai/analysis/permission-scope-resolver.ts \
  apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts
git commit -m "feat(ai-analysis): 添加分析权限范围解析"

Task 4: Metric Engine MVP Queries

Files:

  • Create: apps/server/src/modules/ai/analysis/metric-engine.ts
  • Create: apps/server/src/modules/ai/analysis/metric-engine.spec.ts

Interfaces:

  • Consumes: normalized AnalysisPlan, buildScopedWhere(scope), Prisma relation models.

  • Produces:

    • executeMetric(plan: AnalysisPlan, now?: Date): Promise<MetricResult>
  • Step 1: Write failing Metric Engine tests

Create apps/server/src/modules/ai/analysis/metric-engine.spec.ts:

import { MetricEngine } from './metric-engine';

describe('MetricEngine', () => {
  function makeEngine() {
    const prisma = {
      xiaobaoRiskSummary: { findMany: jest.fn() },
      version: { findMany: jest.fn() },
      versionPlan: { findMany: jest.fn() },
      devTask: { findMany: jest.fn() },
      testCase: { findMany: jest.fn() },
      bug: { findMany: jest.fn() },
      requirement: { findMany: jest.fn() },
      taskWorklog: { findMany: jest.fn() },
      overtimeRecord: { findMany: jest.fn() },
      user: { findMany: jest.fn() },
    };
    return { prisma, engine: new MetricEngine(prisma as any) };
  }

  it('returns version risk ranking from Xiaobao summaries', async () => {
    const { prisma, engine } = makeEngine();
    prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
      { versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
      { versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
    ]);
    prisma.version.findMany.mockResolvedValue([
      { id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
      { id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
    ]);

    const result = await engine.executeMetric({
      metricRef: { metricId: 'version_risk_score', version: 1 },
      analysisType: 'ranking',
      dimensions: ['version'],
      filters: {},
      scope: { type: 'project', projectId: 'project-1' },
      limit: 10,
    }, new Date('2026-07-08T12:00:00.000Z'));

    expect(result.rows).toEqual([
      { versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
      { versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
    ]);
    expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
  });

  it('groups member pending work across plans, tasks, cases, and bugs', async () => {
    const { prisma, engine } = makeEngine();
    prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
    prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
    prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
    prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
    prisma.user.findMany.mockResolvedValue([
      { id: 'm-1', name: '张三', departmentId: '研发' },
      { id: 'm-2', name: '李四', departmentId: '测试' },
    ]);

    const result = await engine.executeMetric({
      metricRef: { metricId: 'member_pending_work', version: 1 },
      analysisType: 'ranking',
      dimensions: ['member'],
      filters: {},
      scope: { type: 'version', versionId: 'ver-1' },
      limit: 10,
    });

    expect(result.rows).toEqual([
      { memberId: 'm-1', label: '张三', value: 3 },
      { memberId: 'm-2', label: '李四', value: 1 },
    ]);
  });

  it('returns no-data evidence for empty requirement trends', async () => {
    const { prisma, engine } = makeEngine();
    prisma.requirement.findMany.mockResolvedValue([]);

    const result = await engine.executeMetric({
      metricRef: { metricId: 'requirement_completion_count', version: 1 },
      analysisType: 'trend',
      dimensions: ['day'],
      filters: {},
      scope: { type: 'self', userId: 'm-1' },
      timeRange: {
        start: '2026-06-09T00:00:00.000Z',
        end: '2026-07-08T23:59:59.999Z',
        policy: 'last_30_days',
      },
    });

    expect(result.rows).toEqual([]);
    expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
  });
});

Run: pnpm --filter server test -- metric-engine

Expected before implementation: FAIL because MetricEngine does not exist.

  • Step 2: Implement MetricEngine with dispatcher

Create apps/server/src/modules/ai/analysis/metric-engine.ts with:

import { Injectable } from '@nestjs/common';
import type { AnalysisPlan, EvidenceItem, MetricResult } from '@ftb/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { buildScopedWhere } from './permission-scope-resolver';

type OpenWorkItem = {
  id: string;
  versionId: string;
  title: string;
  ownerId?: string | null;
  assigneeId?: string | null;
};

@Injectable()
export class MetricEngine {
  constructor(private readonly prisma: PrismaService) {}

  async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
    if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
    if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
    if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
    if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
    if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
    if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
    if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
    if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
    if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
    if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
    if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
    if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
    if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
    if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
    return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
  }

  private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const scopedWhere = buildScopedWhere(plan.scope);
    const versionWhere = 'versionId' in scopedWhere ? { id: scopedWhere.versionId } : scopedWhere;
    const versions = await this.prisma.version.findMany({ where: versionWhere, select: { id: true, name: true, projectId: true, productId: true } });
    const versionById = new Map(versions.map((version: any) => [version.id, version]));
    const rows = await this.prisma.xiaobaoRiskSummary.findMany({
      where: { versionId: { in: versions.map((version: any) => version.id) } },
      orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
      take: plan.limit ?? 10,
    });
    return {
      metricRef: plan.metricRef,
      analysisType: plan.analysisType,
      columns: [
        { id: 'label', label: '版本', type: 'string' },
        { id: 'value', label: '风险分', type: 'number' },
        { id: 'riskLevel', label: '风险等级', type: 'string' },
      ],
      rows: rows.map((row: any) => ({
        versionId: row.versionId,
        label: versionById.get(row.versionId)?.name ?? row.versionId,
        value: row.riskScore,
        riskLevel: row.riskLevel,
      })),
      evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
      dataScope: plan.scope,
      generatedAt: now.toISOString(),
    };
  }

  private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where = buildScopedWhere(plan.scope);
    const [plans, devTasks, testCases, bugs] = await Promise.all([
      this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' } } }),
      this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' } } }),
      this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] } } }),
      this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } } }),
    ]);
    const counts = new Map<string, number>();
    for (const item of [...plans.map((p: any) => ({ ...p, assigneeId: p.ownerId })), ...devTasks, ...testCases, ...bugs] as OpenWorkItem[]) {
      const memberId = item.assigneeId ?? item.ownerId;
      if (!memberId) continue;
      counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
    }
    const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(counts.keys()) } }, select: { id: true, name: true, departmentId: true } });
    const userById = new Map(users.map((user: any) => [user.id, user]));
    const rows = Array.from(counts.entries())
      .map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
      .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
      .slice(0, plan.limit ?? 10);
    return {
      metricRef: plan.metricRef,
      analysisType: plan.analysisType,
      columns: [
        { id: 'label', label: '成员', type: 'string' },
        { id: 'value', label: '待办数', type: 'number' },
      ],
      rows,
      evidence: [{ label: '未完成事项', value: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), sourceDomain: 'dev_task' }],
      dataScope: plan.scope,
      generatedAt: now.toISOString(),
    };
  }

  private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const memberResult = await this.memberPendingWork({ ...plan, metricRef: { metricId: 'member_pending_work', version: 1 }, dimensions: ['member'] }, now);
    const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
    const users = await this.prisma.user.findMany({ where: { id: { in: memberIds } }, select: { id: true, departmentId: true } });
    const departmentByMember = new Map(users.map((user: any) => [user.id, user.departmentId || '未分部门']));
    const counts = new Map<string, number>();
    for (const row of memberResult.rows) {
      const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
      counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
    }
    return rowsResult(plan, now, '部门', '待办数', Array.from(counts.entries()).map(([label, value]) => ({ label, value })), 'dev_task');
  }

  private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where = buildScopedWhere(plan.scope);
    const [plans, devTasks, testCases, bugs] = await Promise.all([
      this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' }, expectedEndAt: { lt: now } } }),
      this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' }, expectedEndAt: { lt: now } } }),
      this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] }, plannedEndAt: { lt: now } } }),
      this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } } }),
    ]);
    const byVersion = new Map<string, number>();
    for (const item of [...plans, ...devTasks, ...testCases, ...bugs] as any[]) {
      if (!item.versionId) continue;
      byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
    }
    return rowsResult(plan, now, '版本', '逾期数', Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })), 'version');
  }

  private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const rows = await this.prisma.requirement.findMany({ where: buildScopedWhere(plan.scope), select: { status: true } });
    return countRows(plan, now, rows.map((row: any) => row.status || 'unknown'), '状态', '数量', 'requirement');
  }

  private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['released', 'closed'] } };
    if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
    const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } });
    return trendRows(plan, now, rows.map((row: any) => row.updatedAt), '完成需求', 'requirement');
  }

  private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where: any = buildScopedWhere(plan.scope);
    if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
    const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } });
    const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
    return countRows(plan, now, rows.map((row: any) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
  }

  private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where: any = buildScopedWhere(plan.scope);
    if (plan.timeRange) {
      where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
    }
    const [worklogs, overtime] = await Promise.all([
      this.prisma.taskWorklog.findMany({ where, select: { userId: true, hours: true } }),
      this.prisma.overtimeRecord.findMany({ where, select: { userId: true, hours: true } }),
    ]);
    const hours = new Map<string, number>();
    for (const row of [...worklogs, ...overtime] as any[]) {
      if (!row.userId) continue;
      hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
    }
    const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(hours.keys()) } }, select: { id: true, name: true } });
    const userById = new Map(users.map((user: any) => [user.id, user.name]));
    return rowsResult(plan, now, '成员', '小时', Array.from(hours.entries()).map(([memberId, value]) => ({ memberId, label: userById.get(memberId) ?? memberId, value })), 'task_worklog');
  }

  private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const rows = await this.prisma.bug.findMany({ where: { ...buildScopedWhere(plan.scope), status: { notIn: ['closed', 'rejected'] } }, select: { severity: true } });
    return countRows(plan, now, rows.map((row: any) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
  }

  private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['passed', 'failed', 'blocked'] } };
    if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
    const rows = await this.prisma.testCase.findMany({ where, select: { status: true, updatedAt: true } });
    const buckets = bucketDates(rows.map((row: any) => row.updatedAt));
    const source = Array.from(buckets.entries()).map(([label]) => {
      const sameDay = rows.filter((row: any) => dayKey(row.updatedAt) === label);
      const passed = sameDay.filter((row: any) => row.status === 'passed').length;
      return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
    });
    return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
  }

  private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const where: any = buildScopedWhere(plan.scope);
    if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
    const rows = await this.prisma.overtimeRecord.findMany({ where, select: { reason: true, hours: true } });
    const hours = new Map<string, number>();
    for (const row of rows as any[]) {
      const reason = row.reason || '未填写';
      hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
    }
    return rowsResult(plan, now, '原因', '小时', Array.from(hours.entries()).map(([label, value]) => ({ label, value })), 'overtime');
  }

  private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const requirementPlan = { ...plan, metricRef: { metricId: 'requirement_completion_count' as const, version: 1 }, dimensions: ['day' as const] };
    return this.requirementCompletionTrend(requirementPlan, now);
  }

  private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    const overdue = await this.overdueItemCount({ ...plan, metricRef: { metricId: 'overdue_item_count', version: 1 }, dimensions: ['version'], analysisType: 'ranking' }, now);
    const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
    return { ...overdue, metricRef: plan.metricRef, rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })) };
  }

  private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
    return this.overtimeReasonHours({ ...plan, metricRef: { metricId: 'overtime_reason_hours', version: 1 }, dimensions: ['delay_reason'] }, now);
  }
}

function rowsResult(plan: AnalysisPlan, now: Date, labelName: string, valueName: string, rows: Array<Record<string, string | number | null>>, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
  const sorted = rows.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0)).slice(0, plan.limit ?? rows.length);
  return {
    metricRef: plan.metricRef,
    analysisType: plan.analysisType,
    columns: [
      { id: 'label', label: labelName, type: 'string' },
      { id: 'value', label: valueName, type: 'number' },
    ],
    rows: sorted,
    evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
    dataScope: plan.scope,
    generatedAt: now.toISOString(),
  };
}

function countRows(plan: AnalysisPlan, now: Date, labels: string[], labelName: string, valueName: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
  const counts = new Map<string, number>();
  for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
  return rowsResult(plan, now, labelName, valueName, Array.from(counts.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}

function trendRows(plan: AnalysisPlan, now: Date, dates: Date[], valueLabel: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
  const buckets = bucketDates(dates);
  return rowsResult(plan, now, '日期', valueLabel, Array.from(buckets.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}

function bucketDates(dates: Date[]) {
  const buckets = new Map<string, number>();
  for (const date of dates) {
    const key = dayKey(date);
    buckets.set(key, (buckets.get(key) ?? 0) + 1);
  }
  return buckets;
}

function dayKey(value: Date | string) {
  return new Date(value).toISOString().slice(0, 10);
}

function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
  return {
    metricRef: plan.metricRef,
    analysisType: plan.analysisType,
    columns: [],
    rows: [],
    evidence,
    dataScope: plan.scope,
    generatedAt: now.toISOString(),
  };
}
  • Step 3: Run focused engine tests

Run: pnpm --filter server test -- metric-engine

Expected: PASS.

  • Step 4: Commit
git add apps/server/src/modules/ai/analysis/metric-engine.ts \
  apps/server/src/modules/ai/analysis/metric-engine.spec.ts
git commit -m "feat(ai-analysis): 实现业务分析指标引擎"

Task 5: Response Builders, AI Planning, and /ai/analysis

Files:

  • Create: apps/server/src/modules/ai/dto/analysis.dto.ts
  • Create: apps/server/src/modules/ai/prompts/analysis-plan.ts
  • Create: apps/server/src/modules/ai/prompts/analysis-report.ts
  • Create: apps/server/src/modules/ai/analysis/analysis-strategy.ts
  • Create: apps/server/src/modules/ai/analysis/chart-spec-builder.ts
  • Create: apps/server/src/modules/ai/analysis/insight-engine.ts
  • Create: apps/server/src/modules/ai/analysis/report-builder.ts
  • Create: apps/server/src/modules/ai/analysis/follow-up-builder.ts
  • Create: apps/server/src/modules/ai/analysis/business-analysis.service.ts
  • Create: apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts
  • Modify: apps/server/src/modules/ai/ai.controller.ts
  • Modify: apps/server/src/modules/ai/ai.module.ts

Interfaces:

  • Consumes: Tasks 1-4.

  • Produces:

    • POST /api/v1/ai/analysis
    • BusinessAnalysisService.analyze(req, user, permissions)
    • buildUnifiedChartSpec(result, metric)
    • buildInsightCard(result, semanticConfidence)
    • buildAnalysisReport(result, insight, metric)
    • buildFollowUps(result, plan)
  • Step 1: Write failing orchestration tests

Create apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts:

import { BusinessAnalysisService } from './business-analysis.service';

describe('BusinessAnalysisService', () => {
  function makeService() {
    const scopeResolver = { resolveAnalysisScope: jest.fn().mockResolvedValue({ type: 'version', versionId: 'ver-1' }) };
    const metricEngine = {
      executeMetric: jest.fn().mockResolvedValue({
        metricRef: { metricId: 'version_risk_score', version: 1 },
        analysisType: 'ranking',
        columns: [{ id: 'label', label: '版本', type: 'string' }],
        rows: [{ label: 'V1', value: 88 }],
        evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
        dataScope: { type: 'version', versionId: 'ver-1' },
        generatedAt: '2026-07-08T12:00:00.000Z',
      }),
    };
    const reportBuilder = { build: jest.fn().mockResolvedValue({
      summary: 'V1 风险较高。',
      keyFindings: ['风险分 88。'],
      evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
      suggestions: ['优先处理阻塞和严重 Bug。'],
      dataScope: {
        timeDescription: '当前状态',
        permissionDescription: '当前版本',
        metricFormulaDescription: '小宝风险分',
        generatedAt: '2026-07-08T12:00:00.000Z',
      },
    }) };
    const service = new BusinessAnalysisService(scopeResolver as any, metricEngine as any, reportBuilder as any);
    return { service, scopeResolver, metricEngine, reportBuilder };
  }

  it('returns insight, chart, report, evidence, and follow-ups', async () => {
    const { service } = makeService();

    const result = await service.analyze({
      question: '这个版本风险怎么样',
      context: { surface: 'version_detail', versionId: 'ver-1' },
    }, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z'));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.chart.kind).toBe('horizontal_bar');
      expect(result.insight.summary).toContain('V1');
      expect(result.report.summary).toContain('风险');
      expect(result.followUps.some((item) => item.type === 'question')).toBe(true);
    }
  });

  it('returns NO_DATA when metric result has no rows', async () => {
    const { service, metricEngine } = makeService();
    metricEngine.executeMetric.mockResolvedValueOnce({
      metricRef: { metricId: 'requirement_completion_count', version: 1 },
      analysisType: 'trend',
      columns: [],
      rows: [],
      evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
      dataScope: { type: 'self', userId: 'm-1' },
      generatedAt: '2026-07-08T12:00:00.000Z',
    });

    await expect(service.analyze({ question: '需求完成趋势' }, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z')))
      .resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
  });
});

Run: pnpm --filter server test -- business-analysis.service

Expected before implementation: FAIL because service/builders do not exist.

  • Step 2: Add DTO and controller endpoint

Create apps/server/src/modules/ai/dto/analysis.dto.ts:

import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class AnalysisContextDto {
  @IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
  surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';

  @IsOptional()
  @IsString()
  productId?: string;

  @IsOptional()
  @IsString()
  projectId?: string;

  @IsOptional()
  @IsString()
  versionId?: string;
}

export class AnalysisDto {
  @IsString()
  question!: string;

  @IsOptional()
  @ValidateNested()
  @Type(() => AnalysisContextDto)
  context?: AnalysisContextDto;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  permissions?: string[];
}

Modify apps/server/src/modules/ai/ai.controller.ts:

import { Body, Controller, Post } from '@nestjs/common';
import { CurrentUser } from '../../common/auth/current-user.decorator';
import { AiService } from './ai.service';
import { BusinessAnalysisService } from './analysis/business-analysis.service';
import { AnalysisDto } from './dto/analysis.dto';
import { DecomposeDto } from './dto/decompose.dto';
import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type {
  AgentDecomposeResponse,
  AgentDecomposeError,
  AgentRiskInterpretResponse,
  AgentRiskInterpretError,
  AnalysisResponse,
} from '@ftb/shared';
import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.service';

@Controller('ai')
export class AiController {
  constructor(
    private readonly aiService: AiService,
    private readonly businessAnalysisService: BusinessAnalysisService,
  ) {}

  @Post('decompose')
  async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
    return this.aiService.decompose(dto);
  }

  @Post('risk-interpret')
  async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
    return this.aiService.interpretRisk(dto);
  }

  @Post('analysis')
  async analyze(
    @Body() dto: AnalysisDto,
    @CurrentUser() user: ResolvedCurrentUser | null,
  ): Promise<AnalysisResponse> {
    return this.businessAnalysisService.analyze(dto, {
      id: user?.id ?? '',
      permissions: dto.permissions ?? [],
    });
  }
}

Security boundary: dto.permissions is only the caller's current role permission assertion, matching the existing management/governance API pattern. It never widens data by itself. PermissionScopeResolver still verifies project membership through RbacService.assertProjectRole, verifies wildcard/global access through RbacService.assertGlobalPermission, and returns the narrowest DataScope for the request context.

  • Step 3: Add AI Planning prompts and deterministic builders

Create apps/server/src/modules/ai/prompts/analysis-plan.ts:

export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';

export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析计划助手。
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL不能绕过权限。
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
当问题无法映射到已暴露能力时,返回 clarificationOptions不要编造指标。
`.trim();

export const ANALYSIS_PLAN_TOOL_DESCRIPTION = 'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';

export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['metricId', 'analysisType', 'dimensions', 'filters'],
  properties: {
    metricId: {
      type: 'string',
      enum: [
        'version_risk_score',
        'completion_trend',
        'overdue_item_count',
        'requirement_status_count',
        'requirement_completion_count',
        'requirement_source_count',
        'department_workload',
        'member_pending_work',
        'member_effort_hours',
        'bug_severity_count',
        'test_pass_rate',
        'overtime_reason_hours',
        'delay_rate',
        'delay_reason_count',
      ],
    },
    metricVersion: { type: 'number' },
    analysisType: {
      type: 'string',
      enum: ['ranking', 'trend', 'comparison', 'distribution', 'composition', 'correlation', 'breakdown', 'summary'],
    },
    dimensions: {
      type: 'array',
      items: {
        type: 'string',
        enum: [
          'product',
          'project',
          'version',
          'requirement_status',
          'requirement_type',
          'requirement_source',
          'department',
          'member',
          'role',
          'month',
          'week',
          'day',
          'bug_severity',
          'bug_status',
          'test_status',
          'delay_reason',
        ],
      },
    },
    filters: { type: 'object', additionalProperties: true },
    timeRange: {
      type: 'object',
      additionalProperties: false,
      required: ['start', 'end'],
      properties: {
        start: { type: 'string' },
        end: { type: 'string' },
        policy: { type: 'string', enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'] },
      },
    },
    limit: { type: 'number', minimum: 1, maximum: 20 },
    sort: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['field', 'direction'],
        properties: {
          field: { type: 'string' },
          direction: { type: 'string', enum: ['asc', 'desc'] },
        },
      },
    },
  },
} as const;

Create apps/server/src/modules/ai/prompts/analysis-report.ts:

export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';

export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析报告助手。
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
`.trim();

export const ANALYSIS_REPORT_TOOL_DESCRIPTION = 'Write a structured business analysis report from deterministic metric results without inventing facts.';

export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['summary', 'keyFindings', 'suggestions'],
  properties: {
    summary: { type: 'string' },
    keyFindings: { type: 'array', items: { type: 'string' } },
    suggestions: { type: 'array', items: { type: 'string' } },
  },
} as const;

Create these builder files with deterministic outputs first:

apps/server/src/modules/ai/analysis/chart-spec-builder.ts:

import type { MetricDefinition, MetricResult, UnifiedChartSpec } from '@ftb/shared';

export function buildUnifiedChartSpec(result: MetricResult, metric: MetricDefinition): UnifiedChartSpec {
  const labelField = result.columns.find((column) => column.type === 'string')?.id ?? 'label';
  const valueField = result.columns.find((column) => column.type === 'number' || column.type === 'percent')?.id ?? 'value';
  return {
    kind: metric.defaultChart,
    title: metric.name,
    subtitle: metric.description,
    dataset: {
      source: result.rows,
      label: labelField,
      value: valueField,
      x: metric.defaultChart === 'line_area' ? labelField : undefined,
      y: metric.defaultChart === 'line_area' ? valueField : undefined,
    },
    encoding: {
      x: metric.defaultChart === 'line_area' ? { field: labelField, label: result.columns.find((column) => column.id === labelField)?.label ?? labelField } : undefined,
      y: metric.defaultChart === 'line_area' ? { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField } : undefined,
      value: { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField },
      color: { mode: result.metricRef.metricId === 'version_risk_score' ? 'risk' : 'single' },
    },
    annotations: [],
    stylePreset: 'apple_vision_light',
  };
}

apps/server/src/modules/ai/analysis/insight-engine.ts:

import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';

export function buildInsightCard(
  result: MetricResult,
  metric: MetricDefinition,
  semanticConfidence: InsightCard['semanticConfidence'],
): InsightCard {
  const top = result.rows[0];
  const value = top?.value ?? result.totals?.value ?? 0;
  const label = String(top?.label ?? metric.name);
  const dataConfidence: InsightCard['dataConfidence'] = result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
  return {
    summary: result.rows.length === 0 ? `${metric.name}暂无可分析数据。` : `${label}${metric.name}中最突出。`,
    primaryValue: { label: metric.name, value },
    semanticConfidence,
    dataConfidence,
  };
}

apps/server/src/modules/ai/analysis/report-builder.ts:

import { Injectable } from '@nestjs/common';
import type { AnalysisReport, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';

@Injectable()
export class AnalysisReportBuilder {
  async build(result: MetricResult, insight: InsightCard, metric: MetricDefinition): Promise<AnalysisReport> {
    return {
      summary: insight.summary,
      keyFindings: result.rows.slice(0, 4).map((row) => `${row.label ?? '项目'}${row.value ?? 0}`),
      evidence: result.evidence,
      suggestions: result.rows.length > 0 ? ['优先查看排名靠前的对象,并进入明细确认原因。'] : ['调整时间范围或切换分析维度。'],
      dataScope: {
        timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态',
        permissionDescription: describeScope(result.dataScope),
        metricFormulaDescription: metric.formula,
        generatedAt: result.generatedAt,
      },
    };
  }
}

function resultHasTime(result: MetricResult): boolean {
  return result.columns.some((column) => column.type === 'date');
}

function describeScope(scope: MetricResult['dataScope']) {
  if (scope.type === 'system') return '系统管理范围';
  if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
  if (scope.type === 'self') return '与当前用户相关的数据';
  if (scope.type === 'product') return `产品范围:${scope.productId}`;
  if (scope.type === 'project') return `项目范围:${scope.projectId}`;
  return `版本范围:${scope.versionId}`;
}

apps/server/src/modules/ai/analysis/follow-up-builder.ts:

import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';

export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
  const top = result.rows[0];
  const baseQuestion = top?.label ? `为什么${top.label}最高?` : '换一个维度继续分析';
  return [
    { type: 'question', label: '继续分析原因', prompt: baseQuestion },
    { type: 'drilldown', label: '查看明细', target: plan.dimensions[0] ?? 'analysis', filters: plan.filters },
    { type: 'export', label: '导出报告', format: 'pdf' },
  ];
}
  • Step 4: Implement strategy and service

Create apps/server/src/modules/ai/analysis/analysis-strategy.ts:

import type { AnalysisRequest, AnalysisPlan } from '@ftb/shared';
import { parseSemanticIntent } from './analysis-semantic-layer';
import { normalizeAnalysisPlan } from './analysis-plan-processor';

export function createAnalysisPlanFromQuestion(
  request: AnalysisRequest,
  scope: AnalysisPlan['scope'],
  now = new Date(),
) {
  const semantic = parseSemanticIntent(request.question);
  const dimensions = inferDimensions(request.question, semantic.metricId);
  const plan = normalizeAnalysisPlan({
    metricId: semantic.metricId,
    analysisType: semantic.analysisType,
    dimensions,
    scope,
    filters: {},
  }, now);
  return { semantic, plan };
}

function inferDimensions(question: string, metricId: AnalysisPlan['metricRef']['metricId']): AnalysisPlan['dimensions'] {
  if (/部门/.test(question)) return ['department'];
  if (/成员|谁|负责人/.test(question)) return ['member'];
  if (/产品/.test(question)) return ['product'];
  if (/项目/.test(question)) return ['project'];
  if (/月|月份/.test(question)) return ['month'];
  if (/周/.test(question)) return ['week'];
  if (metricId === 'version_risk_score') return ['version'];
  if (metricId === 'bug_severity_count') return ['bug_severity'];
  if (metricId === 'requirement_status_count') return ['requirement_status'];
  if (metricId === 'requirement_source_count') return ['requirement_source'];
  return ['version'];
}

Create apps/server/src/modules/ai/analysis/business-analysis.service.ts:

import { Injectable } from '@nestjs/common';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';
import { PermissionScopeResolver } from './permission-scope-resolver';
import { MetricEngine } from './metric-engine';
import { AnalysisReportBuilder } from './report-builder';
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
import { buildUnifiedChartSpec } from './chart-spec-builder';
import { buildInsightCard } from './insight-engine';
import { buildFollowUps } from './follow-up-builder';

export interface AnalysisActor {
  id: string;
  permissions: string[];
}

@Injectable()
export class BusinessAnalysisService {
  constructor(
    private readonly scopeResolver: PermissionScopeResolver,
    private readonly metricEngine: MetricEngine,
    private readonly reportBuilder: AnalysisReportBuilder,
  ) {}

  async analyze(request: AnalysisRequest, actor: AnalysisActor, now = new Date()): Promise<AnalysisResponse> {
    const question = request.question.trim();
    if (!question) {
      return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
    }
    const scope = await this.scopeResolver.resolveAnalysisScope({
      actorId: actor.id,
      permissions: actor.permissions,
      context: request.context,
    });
    const { semantic, plan } = createAnalysisPlanFromQuestion(request, scope, now);
    if (semantic.semanticConfidence === 'low') {
      return {
        ok: false,
        code: 'AMBIGUOUS_INTENT',
        message: '这个问题有多种理解,请选择一个分析方向。',
        clarificationOptions: [
          { label: '成员负载', prompt: '分析成员待办排行' },
          { label: '版本风险', prompt: '分析版本风险排行' },
        ],
        dataScope: scope,
      };
    }
    const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
    if (!metric) return { ok: false, code: 'UNSUPPORTED_ANALYSIS', message: '当前指标不在分析目录中。', dataScope: scope };
    const metricResult = await this.metricEngine.executeMetric(plan, now);
    if (metricResult.rows.length === 0) {
      return {
        ok: false,
        code: 'NO_DATA',
        message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
        dataScope: scope,
      };
    }
    const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
    const chart = buildUnifiedChartSpec(metricResult, metric);
    const report = await this.reportBuilder.build(metricResult, insight, metric);
    const followUps = buildFollowUps(metricResult, plan);
    return { ok: true, plan, metricResult, insight, chart, report, followUps };
  }
}
  • Step 5: Wire module providers

Modify apps/server/src/modules/ai/ai.module.ts:

import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiGatewayService } from './ai-gateway.service';
import { ConfigModule } from '../config/config.module';
import { BusinessAnalysisService } from './analysis/business-analysis.service';
import { MetricEngine } from './analysis/metric-engine';
import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
import { AnalysisReportBuilder } from './analysis/report-builder';

@Module({
  imports: [ConfigModule, CommonDomainModule],
  controllers: [AiController],
  providers: [
    AiService,
    AiGatewayService,
    BusinessAnalysisService,
    PermissionScopeResolver,
    MetricEngine,
    AnalysisReportBuilder,
  ],
  exports: [AiService],
})
export class AiModule {}
  • Step 6: Run backend focused tests

Run: pnpm --filter server test -- business-analysis.service ai.service

Expected: PASS.

  • Step 7: Run server type-check

Run: pnpm --filter server type-check

Expected: command exits 0.

  • Step 8: Commit
git add apps/server/src/modules/ai
git commit -m "feat(ai-analysis): 接入业务分析接口"

Task 6: Frontend Analysis API and ECharts Renderer

Files:

  • Modify: apps/web/package.json
  • Modify: pnpm-lock.yaml
  • Create: apps/web/lib/analysis-api.ts
  • Create: apps/web/lib/analysis-api.test.ts
  • Create: apps/web/lib/analysis-chart-renderer.ts
  • Create: apps/web/lib/analysis-chart-renderer.test.ts
  • Create: apps/web/components/analysis/AnalysisChart.tsx

Interfaces:

  • Consumes: AnalysisRequest, AnalysisResponse, UnifiedChartSpec from @ftb/shared.

  • Produces:

    • requestAnalysis(request: AnalysisRequest, permissions?: string[]): Promise<AnalysisResponse>
    • toEChartsOption(spec: UnifiedChartSpec): EChartsOption
    • <AnalysisChart spec={chart} />
  • Step 1: Add ECharts dependencies

Run: pnpm add --filter web echarts echarts-for-react

Expected: apps/web/package.json includes echarts and echarts-for-react; pnpm-lock.yaml updates.

  • Step 2: Write failing renderer tests

Create apps/web/lib/analysis-chart-renderer.test.ts:

import test from 'node:test';
import assert from 'node:assert/strict';
import { toEChartsOption } from './analysis-chart-renderer';
import type { UnifiedChartSpec } from '@ftb/shared';

test('toEChartsOption renders line_area with smooth line and area gradient', () => {
  const spec: UnifiedChartSpec = {
    kind: 'line_area',
    title: '需求完成趋势',
    dataset: { source: [{ label: '2026-07-01', value: 3 }], x: 'label', y: 'value' },
    encoding: {
      x: { field: 'label', label: '日期' },
      y: { field: 'value', label: '完成数' },
      value: { field: 'value', label: '完成数' },
      color: { mode: 'single' },
    },
    annotations: [{ type: 'peak', label: '峰值', field: 'value', value: 3 }],
    stylePreset: 'apple_vision_light',
  };

  const option: any = toEChartsOption(spec);

  assert.equal(option.series[0].type, 'line');
  assert.equal(option.series[0].smooth, true);
  assert.ok(option.series[0].areaStyle);
  assert.equal(option.xAxis.show, true);
  assert.equal(option.yAxis.splitLine.show, false);
});

test('toEChartsOption renders horizontal_bar with rounded bars and single color', () => {
  const option: any = toEChartsOption({
    kind: 'horizontal_bar',
    title: '部门负载',
    dataset: { source: [{ label: '研发', value: 8 }], label: 'label', value: 'value' },
    encoding: {
      value: { field: 'value', label: '待办数' },
      color: { mode: 'single' },
    },
    stylePreset: 'apple_vision_light',
  });

  assert.equal(option.series[0].type, 'bar');
  assert.deepEqual(option.series[0].itemStyle.borderRadius, [0, 8, 8, 0]);
  assert.equal(option.color.length, 1);
});

Create apps/web/lib/analysis-api.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAnalysis } from './analysis-api';
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';

test('requestAnalysis posts to /ai/analysis', async () => {
  const originalFetch = globalThis.fetch;
  const calls: string[] = [];
  const apiBase = resolveApiBase();
  globalThis.fetch = (async (input: RequestInfo | URL) => {
    calls.push(String(input));
    return new Response(JSON.stringify(calls.length === 1 ? {} : { ok: false, code: 'NO_DATA', message: 'no rows' }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  }) as typeof fetch;

  try {
    __resetApiAvailabilityForTests();
    const result = await requestAnalysis({ question: '哪个部门最忙', context: { surface: 'ai_assistant' } }, ['management:view']);
    assert.equal(result.ok, false);
    assert.deepEqual(calls, [`${apiBase}/config/ai`, `${apiBase}/ai/analysis`]);
  } finally {
    globalThis.fetch = originalFetch;
  }
});

Run: pnpm --filter web test -- analysis-chart-renderer analysis-api

Expected before implementation: FAIL because files/functions do not exist.

  • Step 3: Implement analysis API client

Create apps/web/lib/analysis-api.ts:

import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { api } from './api';

export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise<AnalysisResponse> {
  return api.post<AnalysisResponse>('/ai/analysis', { ...request, permissions });
}
  • Step 4: Implement ChartSpec renderer

Create apps/web/lib/analysis-chart-renderer.ts:

import type { UnifiedChartSpec } from '@ftb/shared';
import type { EChartsOption } from 'echarts';

const ACCENT = '#0f172a';
const MUTED = '#94a3b8';
const RISK = '#f97316';

export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
  if (spec.kind === 'line_area') return lineAreaOption(spec);
  if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
  if (spec.kind === 'donut') return donutOption(spec);
  return numberCardFallbackOption(spec);
}

function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
  const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
  const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
  return {
    color: [ACCENT],
    grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
    tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
    xAxis: {
      type: 'category',
      show: true,
      boundaryGap: false,
      axisTick: { show: false },
      axisLine: { show: false },
      axisLabel: { color: MUTED, fontSize: 11 },
      data: spec.dataset.source.map((row) => row[xField]),
    },
    yAxis: {
      type: 'value',
      show: true,
      axisTick: { show: false },
      axisLine: { show: false },
      axisLabel: { show: false },
      splitLine: { show: false },
    },
    series: [{
      type: 'line',
      smooth: true,
      symbol: 'circle',
      symbolSize: 7,
      data: spec.dataset.source.map((row) => row[yField]),
      lineStyle: { width: 3 },
      areaStyle: { opacity: 0.14 },
      markPoint: buildMarkPoints(spec),
    }],
  };
}

function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
  const labelField = spec.dataset.label ?? 'label';
  const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
  const rows = spec.dataset.source.slice().reverse();
  return {
    color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
    grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
    tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
    xAxis: { type: 'value', show: false },
    yAxis: {
      type: 'category',
      axisTick: { show: false },
      axisLine: { show: false },
      axisLabel: { color: '#334155', fontSize: 12 },
      data: rows.map((row) => row[labelField]),
    },
    series: [{
      type: 'bar',
      data: rows.map((row) => row[valueField]),
      barWidth: 12,
      itemStyle: { borderRadius: [0, 8, 8, 0] },
      label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
      animationDuration: 520,
    }],
  };
}

function donutOption(spec: UnifiedChartSpec): EChartsOption {
  const labelField = spec.dataset.label ?? 'label';
  const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
  return {
    color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
    tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
    series: [{
      type: 'pie',
      radius: ['62%', '82%'],
      avoidLabelOverlap: true,
      label: { color: '#334155', fontSize: 11 },
      itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
      data: spec.dataset.source.map((row) => ({ name: row[labelField], value: row[valueField] })),
    }],
  };
}

function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
  return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
}

function buildMarkPoints(spec: UnifiedChartSpec) {
  if (!spec.annotations?.length) return undefined;
  return {
    symbolSize: 42,
    label: { fontSize: 10 },
    data: spec.annotations.map((item) => ({ type: item.type === 'peak' ? 'max' : undefined, name: item.label, value: item.value })),
  };
}
  • Step 5: Implement AnalysisChart component

Create apps/web/components/analysis/AnalysisChart.tsx:

'use client';

import dynamic from 'next/dynamic';
import type { UnifiedChartSpec } from '@ftb/shared';
import { toEChartsOption } from '@/lib/analysis-chart-renderer';

const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });

export function AnalysisChart({ spec }: { spec: UnifiedChartSpec }) {
  return (
    <div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
      <div className="mb-3">
        <h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
        {spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
      </div>
      <ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
    </div>
  );
}
  • Step 6: Run frontend focused tests

Run: pnpm --filter web test -- analysis-chart-renderer analysis-api

Expected: PASS.

  • Step 7: Commit
git add apps/web/package.json pnpm-lock.yaml \
  apps/web/lib/analysis-api.ts apps/web/lib/analysis-api.test.ts \
  apps/web/lib/analysis-chart-renderer.ts apps/web/lib/analysis-chart-renderer.test.ts \
  apps/web/components/analysis/AnalysisChart.tsx
git commit -m "feat(ai-analysis): 添加图表渲染和分析API客户端"

Task 7: Analysis Result Components and AI Assistant Conversation

Files:

  • Create: apps/web/components/analysis/InsightCard.tsx
  • Create: apps/web/components/analysis/AnalysisReport.tsx
  • Create: apps/web/components/analysis/EvidenceList.tsx
  • Create: apps/web/components/analysis/FollowUpActions.tsx
  • Create: apps/web/components/analysis/AnalysisResultBlock.tsx
  • Modify: apps/web/app/wenfan-xiaobao/page.tsx
  • Test: existing apps/web/lib/wenfan-xiaobao-ui.test.ts plus pnpm --filter web type-check

Interfaces:

  • Consumes: AnalysisResponse from Task 1, requestAnalysis and AnalysisChart from Task 6.

  • Produces: AI Assistant messages of type analysis, analysis_error, and existing help fallback.

  • Step 1: Write component files

Create apps/web/components/analysis/InsightCard.tsx:

import type { InsightCard as InsightCardData } from '@ftb/shared';

export function InsightCard({ insight }: { insight: InsightCardData }) {
  return (
    <section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
      {insight.primaryValue && (
        <div className="mb-3">
          <div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">{insight.primaryValue.value}{insight.primaryValue.unit ?? ''}</div>
          <div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
        </div>
      )}
      <p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
      {(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
        <p className="mt-3 text-[12px] text-[#64748b]">
          语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
        </p>
      )}
    </section>
  );
}

function confidenceLabel(value: InsightCardData['semanticConfidence']) {
  return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
}

function dataLabel(value: InsightCardData['dataConfidence']) {
  return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
}

Create apps/web/components/analysis/EvidenceList.tsx:

import type { EvidenceItem } from '@ftb/shared';

export function EvidenceList({ items }: { items: EvidenceItem[] }) {
  if (items.length === 0) return null;
  return (
    <div className="flex flex-wrap gap-2">
      {items.map((item, index) => (
        <button
          key={`${item.label}-${index}`}
          type="button"
          className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
          disabled={!item.drilldown}
          title={item.sourceLabel ?? item.sourceDomain}
        >
          <span className="text-[#64748b]">{item.label}</span>
          <span className="ml-1 font-semibold text-[#0f172a]">{item.value}{item.unit ?? ''}</span>
        </button>
      ))}
    </div>
  );
}

Create apps/web/components/analysis/AnalysisReport.tsx:

import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
import { EvidenceList } from './EvidenceList';

export function AnalysisReport({ report }: { report: AnalysisReportData }) {
  return (
    <section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
      <h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
      <p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
      <ReportSection title="关键发现" items={report.keyFindings} />
      <div className="mt-4">
        <p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
        <EvidenceList items={report.evidence} />
      </div>
      <ReportSection title="建议动作" items={report.suggestions} />
      <div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
        <p>{report.dataScope.timeDescription}</p>
        <p>{report.dataScope.permissionDescription}</p>
        <p>{report.dataScope.metricFormulaDescription}</p>
      </div>
    </section>
  );
}

function ReportSection({ title, items }: { title: string; items: string[] }) {
  if (items.length === 0) return null;
  return (
    <div className="mt-4">
      <p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
      <ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
        {items.map((item) => <li key={item}> {item}</li>)}
      </ul>
    </div>
  );
}

Create apps/web/components/analysis/FollowUpActions.tsx:

import type { FollowUp } from '@ftb/shared';

export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
  if (followUps.length === 0) return null;
  return (
    <div className="flex flex-wrap gap-2">
      {followUps.map((item) => (
        <button
          key={`${item.type}-${item.label}`}
          type="button"
          onClick={() => item.type === 'question' ? onAsk(item.prompt) : undefined}
          className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm hover:bg-white"
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}

Create apps/web/components/analysis/AnalysisResultBlock.tsx:

import type { AnalysisResponse } from '@ftb/shared';
import { AnalysisChart } from './AnalysisChart';
import { AnalysisReport } from './AnalysisReport';
import { FollowUpActions } from './FollowUpActions';
import { InsightCard } from './InsightCard';

export function AnalysisResultBlock({ response, onAsk }: { response: AnalysisResponse; onAsk: (prompt: string) => void }) {
  if (!response.ok) {
    return (
      <div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm">
        <p className="font-medium text-[#0f172a]">{response.message}</p>
        {response.clarificationOptions && (
          <div className="mt-3 flex flex-wrap gap-2">
            {response.clarificationOptions.map((option) => (
              <button key={option.prompt} type="button" onClick={() => onAsk(option.prompt)} className="rounded-full border px-3 py-2 text-[13px] hover:bg-[#f8fafc]">
                {option.label}
              </button>
            ))}
          </div>
        )}
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <InsightCard insight={response.insight} />
      <AnalysisChart spec={response.chart} />
      <AnalysisReport report={response.report} />
      <FollowUpActions followUps={response.followUps} onAsk={onAsk} />
    </div>
  );
}
  • Step 2: Modify AI Assistant page message model

In apps/web/app/wenfan-xiaobao/page.tsx, add imports:

import type { AnalysisResponse } from '@ftb/shared';
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
import { requestAnalysis } from '@/lib/analysis-api';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';

Add current role permissions inside WenfanXiaobaoPage:

const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const currentPermissions = useMemo(
  () => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
  [roles, user?.roleId],
);

Extend ChatMessage union:

  | {
      id: string;
      role: 'assistant';
      type: 'analysis';
      response: AnalysisResponse;
    }

Update INITIAL_MESSAGES[0].content:

content:
  '你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',

Change askQuestion to async and call analysis first:

async function askQuestion(rawQuestion: string) {
  const question = rawQuestion.trim();
  if (!question) return;

  const userMessage: ChatMessage = {
    id: `user-${Date.now()}`,
    role: 'user',
    type: 'text',
    content: question,
  };
  const optimisticMessages = [...messages, userMessage];
  setMessages(optimisticMessages);
  setInput('');

  try {
    const analysis = await requestAnalysis(
      { question, context: { surface: 'ai_assistant' } },
      currentPermissions,
    );
    const assistantMessage: ChatMessage = {
      id: `analysis-${Date.now()}`,
      role: 'assistant',
      type: 'analysis',
      response: analysis,
    };
    const nextMessages = [...optimisticMessages, assistantMessage];
    setMessages(nextMessages);
    setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
    return;
  } catch {
    const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
    const nextUserQuestionCount = userQuestionCount + 1;
    const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
    const assistantMessage: ChatMessage =
      results.length > 0
        ? { id: `article-${Date.now()}`, role: 'assistant', type: 'article', result: results[0] }
        : {
            id: `fallback-${Date.now()}`,
            role: 'assistant',
            type: 'fallback',
            content: getFallbackHelpMessage(showFallbackSuggestions),
            suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
          };
    const nextMessages = [...optimisticMessages, assistantMessage];
    setMessages(nextMessages);
    setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
  }
}

Update form handlers to call async function without awaiting:

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  void askQuestion(input);
}

In MessageRow, render analysis:

{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
  • Step 3: Run frontend type-check

Run: pnpm --filter web type-check

Expected: command exits 0.

  • Step 4: Run focused frontend tests

Run: pnpm --filter web test -- analysis-api analysis-chart-renderer wenfan-help-search

Expected: PASS.

  • Step 5: Commit
git add apps/web/components/analysis apps/web/app/wenfan-xiaobao/page.tsx
git commit -m "feat(ai-analysis): 在AI助手展示业务分析结果"

Task 8: Product, Project, and Version Context Entrypoints

Files:

  • Create: apps/web/components/analysis/AnalysisEntryButton.tsx
  • Create: apps/web/components/analysis/AnalysisContextDrawer.tsx
  • Modify: apps/web/app/products/[id]/page.tsx
  • Modify: apps/web/app/projects/[id]/page.tsx
  • Modify: apps/web/app/versions/[id]/page.tsx

Interfaces:

  • Consumes: requestAnalysis, AnalysisResultBlock.

  • Produces: context-aware analysis drawer on product, project, and version detail pages.

  • Step 1: Create context drawer components

Create apps/web/components/analysis/AnalysisEntryButton.tsx:

'use client';

import { BarChart3, Sparkles } from 'lucide-react';

export function AnalysisEntryButton({ onClick, compact = false }: { onClick: () => void; compact?: boolean }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="inline-flex items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 py-1.5 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur hover:bg-white"
      title="智能分析"
    >
      {compact ? <BarChart3 className="h-3.5 w-3.5" /> : <Sparkles className="h-3.5 w-3.5" />}
      智能分析
    </button>
  );
}

Create apps/web/components/analysis/AnalysisContextDrawer.tsx:

'use client';

import { FormEvent, useState } from 'react';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { X } from 'lucide-react';
import { requestAnalysis } from '@/lib/analysis-api';
import { AnalysisResultBlock } from './AnalysisResultBlock';

export function AnalysisContextDrawer({
  open,
  title,
  context,
  permissions,
  onClose,
}: {
  open: boolean;
  title: string;
  context: NonNullable<AnalysisRequest['context']>;
  permissions: string[];
  onClose: () => void;
}) {
  const [question, setQuestion] = useState('');
  const [response, setResponse] = useState<AnalysisResponse | null>(null);
  const [loading, setLoading] = useState(false);
  if (!open) return null;

  async function ask(prompt: string) {
    const value = prompt.trim();
    if (!value) return;
    setLoading(true);
    try {
      setResponse(await requestAnalysis({ question: value, context }, permissions));
      setQuestion('');
    } finally {
      setLoading(false);
    }
  }

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    void ask(question);
  }

  return (
    <div className="fixed inset-0 z-50 flex justify-end bg-black/40">
      <aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
        <header className="flex h-14 items-center justify-between border-b border-white/70 px-5">
          <div>
            <p className="text-[13px] font-semibold text-[#0f172a]">{title}</p>
            <p className="text-[11px] text-[#64748b]">只读取当前上下文和你已有权限的数据</p>
          </div>
          <button type="button" onClick={onClose} className="rounded-full p-2 text-[#64748b] hover:bg-white">
            <X className="h-4 w-4" />
          </button>
        </header>
        <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
          <form onSubmit={submit} className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-sm">
            <textarea
              value={question}
              onChange={(event) => setQuestion(event.target.value)}
              rows={3}
              className="w-full resize-none bg-transparent px-2 py-2 text-[14px] outline-none placeholder:text-[#94a3b8]"
              placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
            />
            <div className="flex justify-end">
              <button disabled={loading || !question.trim()} className="rounded-full bg-[#0f172a] px-4 py-2 text-[13px] font-medium text-white disabled:bg-[#cbd5e1]">
                {loading ? '分析中' : '开始分析'}
              </button>
            </div>
          </form>
          {response && <AnalysisResultBlock response={response} onAsk={ask} />}
        </div>
      </aside>
    </div>
  );
}
  • Step 2: Add version detail entry

In apps/web/app/versions/[id]/page.tsx, import:

import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';

Add state near other modal states:

const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const currentPermissions = currentRole?.permissions ?? [];

Place <AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} /> in the version header action area.

Render drawer near existing modals:

<AnalysisContextDrawer
  open={showAnalysisDrawer}
  title={`版本智能分析 · ${version.name}`}
  context={{ surface: 'version_detail', versionId: version.id }}
  permissions={currentPermissions}
  onClose={() => setShowAnalysisDrawer(false)}
/>
  • Step 3: Add product and project entries

In apps/web/app/products/[id]/page.tsx, import AnalysisEntryButton, AnalysisContextDrawer, useAuthStore, and useMemberStore. Add showAnalysisDrawer state plus:

const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const currentPermissions = useMemo(
  () => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
  [roles, user?.roleId],
);

Place the button in the product page header and render:

<AnalysisContextDrawer
  open={showAnalysisDrawer}
  title={`智能分析 · ${currentProduct.name}`}
  context={{ surface: 'product_detail', productId: currentProduct.id }}
  permissions={currentPermissions}
  onClose={() => setShowAnalysisDrawer(false)}
/>

In apps/web/app/projects/[id]/page.tsx, reuse the existing user and roles variables, add:

const currentPermissions = useMemo(
  () => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
  [roles, user?.roleId],
);

Place the button in the project page header and render:

<AnalysisContextDrawer
  open={showAnalysisDrawer}
  title={`项目智能分析 · ${project.name}`}
  context={{ surface: 'project_detail', projectId: project.id }}
  permissions={currentPermissions}
  onClose={() => setShowAnalysisDrawer(false)}
/>
  • Step 4: Run frontend type-check

Run: pnpm --filter web type-check

Expected: command exits 0.

  • Step 5: Commit
git add apps/web/components/analysis/AnalysisEntryButton.tsx \
  apps/web/components/analysis/AnalysisContextDrawer.tsx \
  'apps/web/app/products/[id]/page.tsx' \
  'apps/web/app/projects/[id]/page.tsx' \
  'apps/web/app/versions/[id]/page.tsx'
git commit -m "feat(ai-analysis): 添加详情页智能分析入口"

Task 9: Documentation, Verification, and UI QA

Files:

  • Modify: docs/agent-spec.md
  • Modify: docs/roadmap.md
  • Modify: docs/workflow.md
  • Optional screenshots saved outside git unless the user asks to keep them.

Interfaces:

  • Consumes: all previous tasks.

  • Produces: final documented implementation boundary and verification evidence.

  • Step 1: Update docs after implementation

Update docs/agent-spec.md Business Analysis Agent section:

Business Analysis Agent 第一版已接入 `/api/v1/ai/analysis``/wenfan-xiaobao` 和产品/项目/版本详情页上下文入口。第一版支持 Template Strategy 和 Deterministic Rule CompositionAI Planning 只生成受控计划建议并经过 Analysis Plan Processor 校验。图表契约为 Unified ChartSpec前端通过 ECharts Renderer 渲染。

Update docs/roadmap.md V3.4:

### V3.4 — Business Analysis Agent已完成第一版

Add completion bullets for:

  • Shared analysis contract.
  • Backend analysis endpoint and Metric Engine.
  • ECharts Renderer.
  • AI Assistant analysis conversation.
  • Product/project/version context entrypoints.

Update docs/workflow.md with the implemented endpoint:

业务分析请求走 `POST /api/v1/ai/analysis`。前端必须传入 `surface` 和可用上下文 ID后端基于当前用户和上下文再次收窄范围不能只信任前端。
  • Step 2: Run full verification commands

Run:

pnpm --filter @ftb/shared type-check
pnpm --filter server test -- analysis
pnpm --filter server type-check
pnpm --filter web test -- analysis
pnpm --filter web type-check
git diff --check

Expected:

  • Shared type-check exits 0.

  • Server focused tests pass.

  • Server type-check exits 0.

  • Web focused tests pass.

  • Web type-check exits 0.

  • git diff --check prints no whitespace errors.

  • Step 3: Run local UI smoke if dev server is available

If a Next.js dev server is already running, run:

curl -I http://localhost:3000/wenfan-xiaobao
curl -I http://localhost:3000/products
curl -I http://localhost:3000/projects
curl -I http://localhost:3000/versions

Expected: each returns HTTP/1.1 200 or HTTP/1.1 307 for auth redirect depending on current local auth state.

If no dev server is running, start it:

pnpm dev --filter=web

Keep the session running only long enough to verify pages and screenshots, then stop it before final response.

  • Step 4: Visual QA with browser testing

Use the browser testing tool or Playwright to inspect:

  • /wenfan-xiaobao
  • One product detail page
  • One project detail page
  • One version detail page

Checks:

  • Insight Card appears before chart and report.

  • Large number is the primary visual in numeric insight.

  • Horizontal bars are single-color and rounded.

  • Line chart has smooth line, subtle area fill, and minimal axes.

  • Long labels do not overflow on desktop and mobile widths.

  • No-data and no-permission responses do not show empty chart frames.

  • Step 5: Commit final docs and polish

git add docs/agent-spec.md docs/roadmap.md docs/workflow.md
git commit -m "docs(ai-analysis): 更新业务分析Agent落地状态"
  • Step 6: Final branch status

Run:

git status --short
git log --oneline -8

Expected: working tree is clean except for intentionally uncommitted local files, and recent commits show the task commits in order.