From 74c55df59bc209cec5a4fc4a9f63045da2409ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 20:45:32 +0800 Subject: [PATCH] =?UTF-8?q?docs(ai-analysis):=20=E7=BC=96=E5=86=99?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E5=88=86=E6=9E=90Agent=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-08-business-analysis-agent.md | 3026 +++++++++++++++++ 1 file changed, 3026 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-business-analysis-agent.md diff --git a/docs/superpowers/plans/2026-07-08-business-analysis-agent.md b/docs/superpowers/plans/2026-07-08-business-analysis-agent.md new file mode 100644 index 0000000..e86e341 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-business-analysis-agent.md @@ -0,0 +1,3026 @@ +# 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 ( +
+