# 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: ```ts 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; 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; }; } export interface MetricResult { metricRef: MetricRef; analysisType: AnalysisType; columns: Array<{ id: string; label: string; type: 'string' | 'number' | 'date' | 'percent' }>; rows: Array>; totals?: Record; 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>; 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 } | { 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`: ```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** ```bash 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`: ```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`: ```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`: ```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`: ```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: ```ts 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: ```ts 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; 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 { 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** ```bash 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` - `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`: ```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`: ```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 { 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** ```bash 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` - [ ] **Step 1: Write failing Metric Engine tests** Create `apps/server/src/modules/ai/analysis/metric-engine.spec.ts`: ```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: ```ts 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 { 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 { 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 { 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(); 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 { 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(); 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 { 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(); 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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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>, 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(); 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(); 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** ```bash 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`: ```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`: ```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`: ```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 { return this.aiService.decompose(dto); } @Post('risk-interpret') async interpretRisk(@Body() dto: RiskInterpretDto): Promise { return this.aiService.interpretRisk(dto); } @Post('analysis') async analyze( @Body() dto: AnalysisDto, @CurrentUser() user: ResolvedCurrentUser | null, ): Promise { 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`: ```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`: ```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`: ```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`: ```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`: ```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 { 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`: ```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`: ```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`: ```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 { 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`: ```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** ```bash 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` - `toEChartsOption(spec: UnifiedChartSpec): EChartsOption` - `` - [ ] **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`: ```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`: ```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`: ```ts import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared'; import { api } from './api'; export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise { return api.post('/ai/analysis', { ...request, permissions }); } ``` - [ ] **Step 4: Implement ChartSpec renderer** Create `apps/web/lib/analysis-chart-renderer.ts`: ```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`: ```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 (

{spec.title}

{spec.subtitle &&

{spec.subtitle}

}
); } ``` - [ ] **Step 6: Run frontend focused tests** Run: `pnpm --filter web test -- analysis-chart-renderer analysis-api` Expected: PASS. - [ ] **Step 7: Commit** ```bash 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`: ```tsx import type { InsightCard as InsightCardData } from '@ftb/shared'; export function InsightCard({ insight }: { insight: InsightCardData }) { return (
{insight.primaryValue && (
{insight.primaryValue.value}{insight.primaryValue.unit ?? ''}
{insight.primaryValue.label}
)}

{insight.summary}

{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (

语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}

)}
); } 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`: ```tsx import type { EvidenceItem } from '@ftb/shared'; export function EvidenceList({ items }: { items: EvidenceItem[] }) { if (items.length === 0) return null; return (
{items.map((item, index) => ( ))}
); } ``` Create `apps/web/components/analysis/AnalysisReport.tsx`: ```tsx import type { AnalysisReport as AnalysisReportData } from '@ftb/shared'; import { EvidenceList } from './EvidenceList'; export function AnalysisReport({ report }: { report: AnalysisReportData }) { return (

分析报告

{report.summary}

数据依据

{report.dataScope.timeDescription}

{report.dataScope.permissionDescription}

{report.dataScope.metricFormulaDescription}

); } function ReportSection({ title, items }: { title: string; items: string[] }) { if (items.length === 0) return null; return (

{title}

    {items.map((item) =>
  • • {item}
  • )}
); } ``` Create `apps/web/components/analysis/FollowUpActions.tsx`: ```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 (
{followUps.map((item) => ( ))}
); } ``` Create `apps/web/components/analysis/AnalysisResultBlock.tsx`: ```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 (

{response.message}

{response.clarificationOptions && (
{response.clarificationOptions.map((option) => ( ))}
)}
); } return (
); } ``` - [ ] **Step 2: Modify AI Assistant page message model** In `apps/web/app/wenfan-xiaobao/page.tsx`, add imports: ```ts 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`: ```ts 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: ```ts | { id: string; role: 'assistant'; type: 'analysis'; response: AnalysisResponse; } ``` Update `INITIAL_MESSAGES[0].content`: ```ts content: '你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。', ``` Change `askQuestion` to async and call analysis first: ```ts 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: ```ts function handleSubmit(event: FormEvent) { event.preventDefault(); void askQuestion(input); } ``` In `MessageRow`, render analysis: ```tsx {message.type === 'analysis' && } ``` - [ ] **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** ```bash 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`: ```tsx 'use client'; import { BarChart3, Sparkles } from 'lucide-react'; export function AnalysisEntryButton({ onClick, compact = false }: { onClick: () => void; compact?: boolean }) { return ( ); } ``` Create `apps/web/components/analysis/AnalysisContextDrawer.tsx`: ```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; permissions: string[]; onClose: () => void; }) { const [question, setQuestion] = useState(''); const [response, setResponse] = useState(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) { event.preventDefault(); void ask(question); } return (