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

3027 lines
111 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, string | number | boolean | string[] | number[]>;
scope: DataScope;
limit?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
export interface EvidenceItem {
label: string;
value: string | number;
unit?: string;
sourceDomain:
| 'product'
| 'project'
| 'version'
| 'requirement'
| 'version_plan'
| 'dev_task'
| 'test_case'
| 'bug'
| 'work_activity'
| 'task_worklog'
| 'overtime'
| 'xiaobao';
sourceLabel?: string;
drilldown?: {
type: 'list' | 'detail';
target: string;
filters: Record<string, unknown>;
};
}
export interface MetricResult {
metricRef: MetricRef;
analysisType: AnalysisType;
columns: Array<{ id: string; label: string; type: 'string' | 'number' | 'date' | 'percent' }>;
rows: Array<Record<string, string | number | null>>;
totals?: Record<string, string | number>;
comparison?: {
baselineLabel: string;
currentLabel: string;
deltaValue?: number;
deltaPercent?: number;
};
evidence: EvidenceItem[];
dataScope: DataScope;
generatedAt: string;
}
export interface UnifiedChartSpec {
kind: ChartKind;
title: string;
subtitle?: string;
dataset: {
source: Array<Record<string, string | number | null>>;
x?: string;
y?: string;
series?: string;
value?: string;
label?: string;
};
encoding: {
x?: { field: string; label: string };
y?: { field: string; label: string };
value?: { field: string; label: string; unit?: string };
color?: { mode: 'single' | 'risk' | 'semantic'; field?: string };
};
annotations?: Array<{
type: 'outlier' | 'peak' | 'target' | 'threshold';
label: string;
value?: string | number;
field?: string;
}>;
stylePreset: 'apple_vision_light' | 'apple_vision_dark';
}
export interface InsightCard {
summary: string;
primaryValue?: {
label: string;
value: string | number;
unit?: string;
};
comparison?: {
label: string;
direction: 'up' | 'down' | 'flat';
value: string | number;
tone: 'positive' | 'negative' | 'neutral';
};
semanticConfidence: 'high' | 'medium' | 'low';
dataConfidence: 'sufficient' | 'partial' | 'insufficient';
}
export interface AnalysisReport {
summary: string;
keyFindings: string[];
evidence: EvidenceItem[];
suggestions: string[];
dataScope: {
timeDescription: string;
permissionDescription: string;
metricFormulaDescription: string;
generatedAt: string;
};
}
export type FollowUp =
| { type: 'question'; label: string; prompt: string }
| { type: 'drilldown'; label: string; target: string; filters: Record<string, unknown> }
| { type: 'export'; label: string; format: 'png' | 'pdf' | 'csv' };
export interface AnalysisRequest {
question: string;
context?: {
surface: AnalysisSurface;
productId?: string;
projectId?: string;
versionId?: string;
};
}
export type AnalysisErrorCode =
| 'NO_PERMISSION'
| 'UNSUPPORTED_ANALYSIS'
| 'AMBIGUOUS_INTENT'
| 'NO_DATA'
| 'AI_UNAVAILABLE'
| 'INVALID_PLAN';
export type AnalysisResponse =
| {
ok: true;
plan: AnalysisPlan;
metricResult: MetricResult;
insight: InsightCard;
chart: UnifiedChartSpec;
report: AnalysisReport;
followUps: FollowUp[];
}
| {
ok: false;
code: AnalysisErrorCode;
message: string;
clarificationOptions?: Array<{ label: string; prompt: string }>;
dataScope?: DataScope;
};
```
- [ ] **Step 2: Export the shared contract**
Modify `packages/shared/src/index.ts`:
```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<string, string | number | boolean | string[] | number[]>;
timeRange?: { start: string; end: string; policy?: TimePolicy };
limit?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
const MAX_TOP_N = 20;
const DEFAULT_TOP_N = 10;
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
const metric = getMetricDefinition(input.metricId, input.metricVersion);
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
const analysisType = input.analysisType;
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
const timeRange = input.timeRange
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
: needsDefaultTime
? last30Days(now)
: undefined;
const plan: AnalysisPlan = {
metricRef: { metricId: metric.metricId, version: metric.version },
analysisType,
dimensions: input.dimensions.length > 0 ? input.dimensions : metric.defaultDimension ? [metric.defaultDimension] : [],
filters: input.filters ?? {},
scope: input.scope,
...(timeRange ? { timeRange } : {}),
limit: normalizeLimit(input.limit, analysisType),
...(input.sort ? { sort: input.sort } : {}),
};
validateAnalysisPlan(plan);
return plan;
}
export function validateAnalysisPlan(plan: AnalysisPlan): void {
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric || metric.status !== 'active') {
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
}
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
}
for (const dimension of plan.dimensions) {
if (!metric.supportedDimensions.includes(dimension)) {
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
}
}
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
}
if (plan.timeRange) {
const start = Date.parse(plan.timeRange.start);
const end = Date.parse(plan.timeRange.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
throw new BadRequestException('Invalid time range');
}
}
}
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
const value = limit ?? DEFAULT_TOP_N;
return Math.min(Math.max(1, value), MAX_TOP_N);
}
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
const end = new Date(now);
end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 29);
start.setHours(0, 0, 0, 0);
return {
start: start.toISOString(),
end: end.toISOString(),
policy: 'last_30_days',
};
}
```
- [ ] **Step 6: Run focused backend tests**
Run: `pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog`
Expected: PASS for the three new test files.
- [ ] **Step 7: Commit**
```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<DataScope>`
- `buildScopedWhere(scope: DataScope): { productId?: any; projectId?: any; versionId?: any }`
- [ ] **Step 1: Write failing resolver tests**
Create `apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts`:
```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<DataScope> {
const actorId = input.actorId?.trim();
if (!actorId) throw new ForbiddenException('Missing actor scope');
const context = input.context;
if (context?.surface === 'version_detail' && context.versionId) {
const version = await this.prisma.version.findFirst({
where: { id: context.versionId },
select: { id: true, projectId: true },
});
if (!version) throw new NotFoundException('Version not found');
if (version.projectId) {
await this.rbac.assertProjectRole({
actorId,
projectId: version.projectId,
allowedRoles: ['viewer'],
permissions: input.permissions,
});
}
return { type: 'version', versionId: version.id };
}
if (context?.surface === 'project_detail' && context.projectId) {
await this.rbac.assertProjectRole({
actorId,
projectId: context.projectId,
allowedRoles: ['viewer'],
permissions: input.permissions,
});
return { type: 'project', projectId: context.projectId };
}
if (context?.surface === 'product_detail' && context.productId) {
if (input.permissions.includes('*')) return { type: 'product', productId: context.productId };
const project = await this.prisma.project.findFirst({
where: { productId: context.productId, members: { some: { userId: actorId } } },
select: { id: true },
});
if (!project && !input.permissions.includes('product:view')) {
throw new ForbiddenException('No product analysis scope');
}
return { type: 'product', productId: context.productId };
}
if (input.permissions.includes('*')) {
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
return { type: 'system', reason: 'admin' };
}
if (input.permissions.includes('management:view')) {
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
const rows = await this.prisma.projectMember.findMany({
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
select: { projectId: true },
});
return { type: 'managed_projects', projectIds: Array.from(new Set(rows.map((row) => row.projectId))) };
}
return { type: 'self', userId: actorId };
}
}
export function buildScopedWhere(scope: DataScope) {
if (scope.type === 'product') return { productId: scope.productId };
if (scope.type === 'project') return { projectId: scope.projectId };
if (scope.type === 'version') return { versionId: scope.versionId };
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
return {};
}
```
- [ ] **Step 3: Run focused resolver tests**
Run: `pnpm --filter server test -- permission-scope-resolver`
Expected: PASS.
- [ ] **Step 4: Commit**
```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<MetricResult>`
- [ ] **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<MetricResult> {
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
}
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const scopedWhere = buildScopedWhere(plan.scope);
const versionWhere = 'versionId' in scopedWhere ? { id: scopedWhere.versionId } : scopedWhere;
const versions = await this.prisma.version.findMany({ where: versionWhere, select: { id: true, name: true, projectId: true, productId: true } });
const versionById = new Map(versions.map((version: any) => [version.id, version]));
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versions.map((version: any) => version.id) } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
take: plan.limit ?? 10,
});
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '版本', type: 'string' },
{ id: 'value', label: '风险分', type: 'number' },
{ id: 'riskLevel', label: '风险等级', type: 'string' },
],
rows: rows.map((row: any) => ({
versionId: row.versionId,
label: versionById.get(row.versionId)?.name ?? row.versionId,
value: row.riskScore,
riskLevel: row.riskLevel,
})),
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = buildScopedWhere(plan.scope);
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' } } }),
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' } } }),
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] } } }),
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } } }),
]);
const counts = new Map<string, number>();
for (const item of [...plans.map((p: any) => ({ ...p, assigneeId: p.ownerId })), ...devTasks, ...testCases, ...bugs] as OpenWorkItem[]) {
const memberId = item.assigneeId ?? item.ownerId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(counts.keys()) } }, select: { id: true, name: true, departmentId: true } });
const userById = new Map(users.map((user: any) => [user.id, user]));
const rows = Array.from(counts.entries())
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
.slice(0, plan.limit ?? 10);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '成员', type: 'string' },
{ id: 'value', label: '待办数', type: 'number' },
],
rows,
evidence: [{ label: '未完成事项', value: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), sourceDomain: 'dev_task' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const memberResult = await this.memberPendingWork({ ...plan, metricRef: { metricId: 'member_pending_work', version: 1 }, dimensions: ['member'] }, now);
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
const users = await this.prisma.user.findMany({ where: { id: { in: memberIds } }, select: { id: true, departmentId: true } });
const departmentByMember = new Map(users.map((user: any) => [user.id, user.departmentId || '未分部门']));
const counts = new Map<string, number>();
for (const row of memberResult.rows) {
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
}
return rowsResult(plan, now, '部门', '待办数', Array.from(counts.entries()).map(([label, value]) => ({ label, value })), 'dev_task');
}
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = buildScopedWhere(plan.scope);
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' }, expectedEndAt: { lt: now } } }),
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' }, expectedEndAt: { lt: now } } }),
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] }, plannedEndAt: { lt: now } } }),
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } } }),
]);
const byVersion = new Map<string, number>();
for (const item of [...plans, ...devTasks, ...testCases, ...bugs] as any[]) {
if (!item.versionId) continue;
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
}
return rowsResult(plan, now, '版本', '逾期数', Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })), 'version');
}
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.requirement.findMany({ where: buildScopedWhere(plan.scope), select: { status: true } });
return countRows(plan, now, rows.map((row: any) => row.status || 'unknown'), '状态', '数量', 'requirement');
}
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['released', 'closed'] } };
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } });
return trendRows(plan, now, rows.map((row: any) => row.updatedAt), '完成需求', 'requirement');
}
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } });
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
return countRows(plan, now, rows.map((row: any) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
}
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) {
where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
}
const [worklogs, overtime] = await Promise.all([
this.prisma.taskWorklog.findMany({ where, select: { userId: true, hours: true } }),
this.prisma.overtimeRecord.findMany({ where, select: { userId: true, hours: true } }),
]);
const hours = new Map<string, number>();
for (const row of [...worklogs, ...overtime] as any[]) {
if (!row.userId) continue;
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
}
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(hours.keys()) } }, select: { id: true, name: true } });
const userById = new Map(users.map((user: any) => [user.id, user.name]));
return rowsResult(plan, now, '成员', '小时', Array.from(hours.entries()).map(([memberId, value]) => ({ memberId, label: userById.get(memberId) ?? memberId, value })), 'task_worklog');
}
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.bug.findMany({ where: { ...buildScopedWhere(plan.scope), status: { notIn: ['closed', 'rejected'] } }, select: { severity: true } });
return countRows(plan, now, rows.map((row: any) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
}
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['passed', 'failed', 'blocked'] } };
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.testCase.findMany({ where, select: { status: true, updatedAt: true } });
const buckets = bucketDates(rows.map((row: any) => row.updatedAt));
const source = Array.from(buckets.entries()).map(([label]) => {
const sameDay = rows.filter((row: any) => dayKey(row.updatedAt) === label);
const passed = sameDay.filter((row: any) => row.status === 'passed').length;
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
});
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
}
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.overtimeRecord.findMany({ where, select: { reason: true, hours: true } });
const hours = new Map<string, number>();
for (const row of rows as any[]) {
const reason = row.reason || '未填写';
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
}
return rowsResult(plan, now, '原因', '小时', Array.from(hours.entries()).map(([label, value]) => ({ label, value })), 'overtime');
}
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const requirementPlan = { ...plan, metricRef: { metricId: 'requirement_completion_count' as const, version: 1 }, dimensions: ['day' as const] };
return this.requirementCompletionTrend(requirementPlan, now);
}
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const overdue = await this.overdueItemCount({ ...plan, metricRef: { metricId: 'overdue_item_count', version: 1 }, dimensions: ['version'], analysisType: 'ranking' }, now);
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
return { ...overdue, metricRef: plan.metricRef, rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })) };
}
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
return this.overtimeReasonHours({ ...plan, metricRef: { metricId: 'overtime_reason_hours', version: 1 }, dimensions: ['delay_reason'] }, now);
}
}
function rowsResult(plan: AnalysisPlan, now: Date, labelName: string, valueName: string, rows: Array<Record<string, string | number | null>>, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const sorted = rows.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0)).slice(0, plan.limit ?? rows.length);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: labelName, type: 'string' },
{ id: 'value', label: valueName, type: 'number' },
],
rows: sorted,
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
function countRows(plan: AnalysisPlan, now: Date, labels: string[], labelName: string, valueName: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const counts = new Map<string, number>();
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
return rowsResult(plan, now, labelName, valueName, Array.from(counts.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}
function trendRows(plan: AnalysisPlan, now: Date, dates: Date[], valueLabel: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const buckets = bucketDates(dates);
return rowsResult(plan, now, '日期', valueLabel, Array.from(buckets.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}
function bucketDates(dates: Date[]) {
const buckets = new Map<string, number>();
for (const date of dates) {
const key = dayKey(date);
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
return buckets;
}
function dayKey(value: Date | string) {
return new Date(value).toISOString().slice(0, 10);
}
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [],
rows: [],
evidence,
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
```
- [ ] **Step 3: Run focused engine tests**
Run: `pnpm --filter server test -- metric-engine`
Expected: PASS.
- [ ] **Step 4: Commit**
```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<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
@Post('risk-interpret')
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto);
}
@Post('analysis')
async analyze(
@Body() dto: AnalysisDto,
@CurrentUser() user: ResolvedCurrentUser | null,
): Promise<AnalysisResponse> {
return this.businessAnalysisService.analyze(dto, {
id: user?.id ?? '',
permissions: dto.permissions ?? [],
});
}
}
```
Security boundary: `dto.permissions` is only the caller's current role permission assertion, matching the existing management/governance API pattern. It never widens data by itself. `PermissionScopeResolver` still verifies project membership through `RbacService.assertProjectRole`, verifies wildcard/global access through `RbacService.assertGlobalPermission`, and returns the narrowest `DataScope` for the request context.
- [ ] **Step 3: Add AI Planning prompts and deterministic builders**
Create `apps/server/src/modules/ai/prompts/analysis-plan.ts`:
```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<AnalysisReport> {
return {
summary: insight.summary,
keyFindings: result.rows.slice(0, 4).map((row) => `${row.label ?? '项目'}${row.value ?? 0}`),
evidence: result.evidence,
suggestions: result.rows.length > 0 ? ['优先查看排名靠前的对象,并进入明细确认原因。'] : ['调整时间范围或切换分析维度。'],
dataScope: {
timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态',
permissionDescription: describeScope(result.dataScope),
metricFormulaDescription: metric.formula,
generatedAt: result.generatedAt,
},
};
}
}
function resultHasTime(result: MetricResult): boolean {
return result.columns.some((column) => column.type === 'date');
}
function describeScope(scope: MetricResult['dataScope']) {
if (scope.type === 'system') return '系统管理范围';
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
if (scope.type === 'self') return '与当前用户相关的数据';
if (scope.type === 'product') return `产品范围:${scope.productId}`;
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
return `版本范围:${scope.versionId}`;
}
```
`apps/server/src/modules/ai/analysis/follow-up-builder.ts`:
```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<AnalysisResponse> {
const question = request.question.trim();
if (!question) {
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
}
const scope = await this.scopeResolver.resolveAnalysisScope({
actorId: actor.id,
permissions: actor.permissions,
context: request.context,
});
const { semantic, plan } = createAnalysisPlanFromQuestion(request, scope, now);
if (semantic.semanticConfidence === 'low') {
return {
ok: false,
code: 'AMBIGUOUS_INTENT',
message: '这个问题有多种理解,请选择一个分析方向。',
clarificationOptions: [
{ label: '成员负载', prompt: '分析成员待办排行' },
{ label: '版本风险', prompt: '分析版本风险排行' },
],
dataScope: scope,
};
}
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric) return { ok: false, code: 'UNSUPPORTED_ANALYSIS', message: '当前指标不在分析目录中。', dataScope: scope };
const metricResult = await this.metricEngine.executeMetric(plan, now);
if (metricResult.rows.length === 0) {
return {
ok: false,
code: 'NO_DATA',
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
dataScope: scope,
};
}
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
const chart = buildUnifiedChartSpec(metricResult, metric);
const report = await this.reportBuilder.build(metricResult, insight, metric);
const followUps = buildFollowUps(metricResult, plan);
return { ok: true, plan, metricResult, insight, chart, report, followUps };
}
}
```
- [ ] **Step 5: Wire module providers**
Modify `apps/server/src/modules/ai/ai.module.ts`:
```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<AnalysisResponse>`
- `toEChartsOption(spec: UnifiedChartSpec): EChartsOption`
- `<AnalysisChart spec={chart} />`
- [ ] **Step 1: Add ECharts dependencies**
Run: `pnpm add --filter web echarts echarts-for-react`
Expected: `apps/web/package.json` includes `echarts` and `echarts-for-react`; `pnpm-lock.yaml` updates.
- [ ] **Step 2: Write failing renderer tests**
Create `apps/web/lib/analysis-chart-renderer.test.ts`:
```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<AnalysisResponse> {
return api.post<AnalysisResponse>('/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 (
<div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<div className="mb-3">
<h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
{spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
</div>
<ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
</div>
);
}
```
- [ ] **Step 6: Run frontend focused tests**
Run: `pnpm --filter web test -- analysis-chart-renderer analysis-api`
Expected: PASS.
- [ ] **Step 7: Commit**
```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 (
<section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
{insight.primaryValue && (
<div className="mb-3">
<div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">{insight.primaryValue.value}{insight.primaryValue.unit ?? ''}</div>
<div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
</div>
)}
<p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
<p className="mt-3 text-[12px] text-[#64748b]">
语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
</p>
)}
</section>
);
}
function confidenceLabel(value: InsightCardData['semanticConfidence']) {
return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
}
function dataLabel(value: InsightCardData['dataConfidence']) {
return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
}
```
Create `apps/web/components/analysis/EvidenceList.tsx`:
```tsx
import type { EvidenceItem } from '@ftb/shared';
export function EvidenceList({ items }: { items: EvidenceItem[] }) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{items.map((item, index) => (
<button
key={`${item.label}-${index}`}
type="button"
className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
disabled={!item.drilldown}
title={item.sourceLabel ?? item.sourceDomain}
>
<span className="text-[#64748b]">{item.label}</span>
<span className="ml-1 font-semibold text-[#0f172a]">{item.value}{item.unit ?? ''}</span>
</button>
))}
</div>
);
}
```
Create `apps/web/components/analysis/AnalysisReport.tsx`:
```tsx
import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
import { EvidenceList } from './EvidenceList';
export function AnalysisReport({ report }: { report: AnalysisReportData }) {
return (
<section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
<p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
<ReportSection title="关键发现" items={report.keyFindings} />
<div className="mt-4">
<p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
<EvidenceList items={report.evidence} />
</div>
<ReportSection title="建议动作" items={report.suggestions} />
<div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
<p>{report.dataScope.timeDescription}</p>
<p>{report.dataScope.permissionDescription}</p>
<p>{report.dataScope.metricFormulaDescription}</p>
</div>
</section>
);
}
function ReportSection({ title, items }: { title: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="mt-4">
<p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
<ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
{items.map((item) => <li key={item}> {item}</li>)}
</ul>
</div>
);
}
```
Create `apps/web/components/analysis/FollowUpActions.tsx`:
```tsx
import type { FollowUp } from '@ftb/shared';
export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
if (followUps.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{followUps.map((item) => (
<button
key={`${item.type}-${item.label}`}
type="button"
onClick={() => item.type === 'question' ? onAsk(item.prompt) : undefined}
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm hover:bg-white"
>
{item.label}
</button>
))}
</div>
);
}
```
Create `apps/web/components/analysis/AnalysisResultBlock.tsx`:
```tsx
import type { AnalysisResponse } from '@ftb/shared';
import { AnalysisChart } from './AnalysisChart';
import { AnalysisReport } from './AnalysisReport';
import { FollowUpActions } from './FollowUpActions';
import { InsightCard } from './InsightCard';
export function AnalysisResultBlock({ response, onAsk }: { response: AnalysisResponse; onAsk: (prompt: string) => void }) {
if (!response.ok) {
return (
<div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm">
<p className="font-medium text-[#0f172a]">{response.message}</p>
{response.clarificationOptions && (
<div className="mt-3 flex flex-wrap gap-2">
{response.clarificationOptions.map((option) => (
<button key={option.prompt} type="button" onClick={() => onAsk(option.prompt)} className="rounded-full border px-3 py-2 text-[13px] hover:bg-[#f8fafc]">
{option.label}
</button>
))}
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<InsightCard insight={response.insight} />
<AnalysisChart spec={response.chart} />
<AnalysisReport report={response.report} />
<FollowUpActions followUps={response.followUps} onAsk={onAsk} />
</div>
);
}
```
- [ ] **Step 2: Modify AI Assistant page message model**
In `apps/web/app/wenfan-xiaobao/page.tsx`, add imports:
```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<HTMLFormElement>) {
event.preventDefault();
void askQuestion(input);
}
```
In `MessageRow`, render analysis:
```tsx
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
```
- [ ] **Step 3: Run frontend type-check**
Run: `pnpm --filter web type-check`
Expected: command exits `0`.
- [ ] **Step 4: Run focused frontend tests**
Run: `pnpm --filter web test -- analysis-api analysis-chart-renderer wenfan-help-search`
Expected: PASS.
- [ ] **Step 5: Commit**
```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 (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 py-1.5 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur hover:bg-white"
title="智能分析"
>
{compact ? <BarChart3 className="h-3.5 w-3.5" /> : <Sparkles className="h-3.5 w-3.5" />}
智能分析
</button>
);
}
```
Create `apps/web/components/analysis/AnalysisContextDrawer.tsx`:
```tsx
'use client';
import { FormEvent, useState } from 'react';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { X } from 'lucide-react';
import { requestAnalysis } from '@/lib/analysis-api';
import { AnalysisResultBlock } from './AnalysisResultBlock';
export function AnalysisContextDrawer({
open,
title,
context,
permissions,
onClose,
}: {
open: boolean;
title: string;
context: NonNullable<AnalysisRequest['context']>;
permissions: string[];
onClose: () => void;
}) {
const [question, setQuestion] = useState('');
const [response, setResponse] = useState<AnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
if (!open) return null;
async function ask(prompt: string) {
const value = prompt.trim();
if (!value) return;
setLoading(true);
try {
setResponse(await requestAnalysis({ question: value, context }, permissions));
setQuestion('');
} finally {
setLoading(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void ask(question);
}
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
<aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
<header className="flex h-14 items-center justify-between border-b border-white/70 px-5">
<div>
<p className="text-[13px] font-semibold text-[#0f172a]">{title}</p>
<p className="text-[11px] text-[#64748b]">只读取当前上下文和你已有权限的数据</p>
</div>
<button type="button" onClick={onClose} className="rounded-full p-2 text-[#64748b] hover:bg-white">
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<form onSubmit={submit} className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-sm">
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
rows={3}
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] outline-none placeholder:text-[#94a3b8]"
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
/>
<div className="flex justify-end">
<button disabled={loading || !question.trim()} className="rounded-full bg-[#0f172a] px-4 py-2 text-[13px] font-medium text-white disabled:bg-[#cbd5e1]">
{loading ? '分析中' : '开始分析'}
</button>
</div>
</form>
{response && <AnalysisResultBlock response={response} onAsk={ask} />}
</div>
</aside>
</div>
);
}
```
- [ ] **Step 2: Add version detail entry**
In `apps/web/app/versions/[id]/page.tsx`, import:
```ts
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
```
Add state near other modal states:
```ts
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const currentPermissions = currentRole?.permissions ?? [];
```
Place `<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />` in the version header action area.
Render drawer near existing modals:
```tsx
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`版本智能分析 · ${version.name}`}
context={{ surface: 'version_detail', versionId: version.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
```
- [ ] **Step 3: Add product and project entries**
In `apps/web/app/products/[id]/page.tsx`, import `AnalysisEntryButton`, `AnalysisContextDrawer`, `useAuthStore`, and `useMemberStore`. Add `showAnalysisDrawer` state plus:
```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],
);
```
Place the button in the product page header and render:
```tsx
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`智能分析 · ${currentProduct.name}`}
context={{ surface: 'product_detail', productId: currentProduct.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
```
In `apps/web/app/projects/[id]/page.tsx`, reuse the existing `user` and `roles` variables, add:
```ts
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
```
Place the button in the project page header and render:
```tsx
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`项目智能分析 · ${project.name}`}
context={{ surface: 'project_detail', projectId: project.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
```
- [ ] **Step 4: Run frontend type-check**
Run: `pnpm --filter web type-check`
Expected: command exits `0`.
- [ ] **Step 5: Commit**
```bash
git add apps/web/components/analysis/AnalysisEntryButton.tsx \
apps/web/components/analysis/AnalysisContextDrawer.tsx \
'apps/web/app/products/[id]/page.tsx' \
'apps/web/app/projects/[id]/page.tsx' \
'apps/web/app/versions/[id]/page.tsx'
git commit -m "feat(ai-analysis): 添加详情页智能分析入口"
```
---
## Task 9: Documentation, Verification, and UI QA
**Files:**
- Modify: `docs/agent-spec.md`
- Modify: `docs/roadmap.md`
- Modify: `docs/workflow.md`
- Optional screenshots saved outside git unless the user asks to keep them.
**Interfaces:**
- Consumes: all previous tasks.
- Produces: final documented implementation boundary and verification evidence.
- [ ] **Step 1: Update docs after implementation**
Update `docs/agent-spec.md` Business Analysis Agent section:
```md
Business Analysis Agent 第一版已接入 `/api/v1/ai/analysis``/wenfan-xiaobao` 和产品/项目/版本详情页上下文入口。第一版支持 Template Strategy 和 Deterministic Rule CompositionAI Planning 只生成受控计划建议并经过 Analysis Plan Processor 校验。图表契约为 Unified ChartSpec前端通过 ECharts Renderer 渲染。
```
Update `docs/roadmap.md` V3.4:
```md
### V3.4 — Business Analysis Agent已完成第一版
```
Add completion bullets for:
- Shared analysis contract.
- Backend analysis endpoint and Metric Engine.
- ECharts Renderer.
- AI Assistant analysis conversation.
- Product/project/version context entrypoints.
Update `docs/workflow.md` with the implemented endpoint:
```md
业务分析请求走 `POST /api/v1/ai/analysis`。前端必须传入 `surface` 和可用上下文 ID后端基于当前用户和上下文再次收窄范围不能只信任前端。
```
- [ ] **Step 2: Run full verification commands**
Run:
```bash
pnpm --filter @ftb/shared type-check
pnpm --filter server test -- analysis
pnpm --filter server type-check
pnpm --filter web test -- analysis
pnpm --filter web type-check
git diff --check
```
Expected:
- Shared type-check exits `0`.
- Server focused tests pass.
- Server type-check exits `0`.
- Web focused tests pass.
- Web type-check exits `0`.
- `git diff --check` prints no whitespace errors.
- [ ] **Step 3: Run local UI smoke if dev server is available**
If a Next.js dev server is already running, run:
```bash
curl -I http://localhost:3000/wenfan-xiaobao
curl -I http://localhost:3000/products
curl -I http://localhost:3000/projects
curl -I http://localhost:3000/versions
```
Expected: each returns `HTTP/1.1 200` or `HTTP/1.1 307` for auth redirect depending on current local auth state.
If no dev server is running, start it:
```bash
pnpm dev --filter=web
```
Keep the session running only long enough to verify pages and screenshots, then stop it before final response.
- [ ] **Step 4: Visual QA with browser testing**
Use the browser testing tool or Playwright to inspect:
- `/wenfan-xiaobao`
- One product detail page
- One project detail page
- One version detail page
Checks:
- Insight Card appears before chart and report.
- Large number is the primary visual in numeric insight.
- Horizontal bars are single-color and rounded.
- Line chart has smooth line, subtle area fill, and minimal axes.
- Long labels do not overflow on desktop and mobile widths.
- No-data and no-permission responses do not show empty chart frames.
- [ ] **Step 5: Commit final docs and polish**
```bash
git add docs/agent-spec.md docs/roadmap.md docs/workflow.md
git commit -m "docs(ai-analysis): 更新业务分析Agent落地状态"
```
- [ ] **Step 6: Final branch status**
Run:
```bash
git status --short
git log --oneline -8
```
Expected: working tree is clean except for intentionally uncommitted local files, and recent commits show the task commits in order.