111 KiB
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;AnalysisPlanProcessormust 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
ChartRendererboundary. - User-visible confidence is level-based:
high/medium/lowfor semantic confidence andsufficient/partial/insufficientfor 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, andexport. - 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: exportanalysis.
Backend
- Create
apps/server/src/modules/ai/dto/analysis.dto.ts: request DTO forPOST /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: resolvesDataScopefrom 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: addPOST /analysis. - Modify
apps/server/src/modules/ai/ai.module.ts: importCommonDomainModule, 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.tsonly if report/planning helpers require existing provider behavior coverage.
- Create
Frontend
- Modify
apps/web/package.json: addechartsandecharts-for-react. - Lockfile update:
pnpm-lock.yamlviapnpm 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: pureUnifiedChartSpec -> EChartsOptionconversion. - 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 callanalysis-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/analysisheaders ifanalysis-apiusesapi.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.tsthroughpnpm --filter @ftb/shared type-check
Interfaces:
-
Consumes: the spec contract in
docs/superpowers/specs/2026-07-08-business-analysis-agent-design.md. -
Produces:
AnalysisRequestAnalysisResponseAnalysisPlanMetricDefinitionMetricResultUnifiedChartSpecInsightCardAnalysisReportEvidenceItemFollowUp
-
Step 1: Write the shared contract file
Create packages/shared/src/analysis.ts with these exported types:
export type AnalysisSurface = 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
export type MetricId =
| 'version_risk_score'
| 'completion_trend'
| 'overdue_item_count'
| 'requirement_status_count'
| 'requirement_completion_count'
| 'requirement_source_count'
| 'department_workload'
| 'member_pending_work'
| 'member_effort_hours'
| 'bug_severity_count'
| 'test_pass_rate'
| 'overtime_reason_hours'
| 'delay_rate'
| 'delay_reason_count';
export type DimensionId =
| 'product'
| 'project'
| 'version'
| 'requirement_status'
| 'requirement_type'
| 'requirement_source'
| 'department'
| 'member'
| 'role'
| 'month'
| 'week'
| 'day'
| 'bug_severity'
| 'bug_status'
| 'test_status'
| 'delay_reason';
export type AnalysisType =
| 'ranking'
| 'trend'
| 'comparison'
| 'distribution'
| 'composition'
| 'correlation'
| 'breakdown'
| 'summary';
export type TimePolicy = 'current_state' | 'last_30_days' | 'lifecycle' | 'user_required' | 'explicit_range';
export type ChartKind =
| 'number_card'
| 'line_area'
| 'horizontal_bar'
| 'stacked_horizontal_bar'
| 'donut'
| 'table_preview';
export interface MetricRef {
metricId: MetricId;
version: number;
}
export interface MetricDefinition {
metricId: MetricId;
version: number;
name: string;
description: string;
formula: string;
owner: string;
supportedDimensions: DimensionId[];
supportedAnalysisTypes: AnalysisType[];
defaultChart: ChartKind;
defaultDimension?: DimensionId;
defaultTimePolicy: TimePolicy;
status: 'active' | 'deprecated';
}
export type DataScope =
| { type: 'self'; userId: string }
| { type: 'managed_projects'; projectIds: string[] }
| { type: 'product'; productId: string }
| { type: 'project'; projectId: string }
| { type: 'version'; versionId: string }
| { type: 'system'; reason: 'admin' | 'management_permission' };
export interface AnalysisPlan {
metricRef: MetricRef;
analysisType: AnalysisType;
dimensions: DimensionId[];
timeRange?: {
start: string;
end: string;
policy: TimePolicy;
};
filters: Record<string, string | number | boolean | string[] | number[]>;
scope: DataScope;
limit?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
export interface EvidenceItem {
label: string;
value: string | number;
unit?: string;
sourceDomain:
| 'product'
| 'project'
| 'version'
| 'requirement'
| 'version_plan'
| 'dev_task'
| 'test_case'
| 'bug'
| 'work_activity'
| 'task_worklog'
| 'overtime'
| 'xiaobao';
sourceLabel?: string;
drilldown?: {
type: 'list' | 'detail';
target: string;
filters: Record<string, unknown>;
};
}
export interface MetricResult {
metricRef: MetricRef;
analysisType: AnalysisType;
columns: Array<{ id: string; label: string; type: 'string' | 'number' | 'date' | 'percent' }>;
rows: Array<Record<string, string | number | null>>;
totals?: Record<string, string | number>;
comparison?: {
baselineLabel: string;
currentLabel: string;
deltaValue?: number;
deltaPercent?: number;
};
evidence: EvidenceItem[];
dataScope: DataScope;
generatedAt: string;
}
export interface UnifiedChartSpec {
kind: ChartKind;
title: string;
subtitle?: string;
dataset: {
source: Array<Record<string, string | number | null>>;
x?: string;
y?: string;
series?: string;
value?: string;
label?: string;
};
encoding: {
x?: { field: string; label: string };
y?: { field: string; label: string };
value?: { field: string; label: string; unit?: string };
color?: { mode: 'single' | 'risk' | 'semantic'; field?: string };
};
annotations?: Array<{
type: 'outlier' | 'peak' | 'target' | 'threshold';
label: string;
value?: string | number;
field?: string;
}>;
stylePreset: 'apple_vision_light' | 'apple_vision_dark';
}
export interface InsightCard {
summary: string;
primaryValue?: {
label: string;
value: string | number;
unit?: string;
};
comparison?: {
label: string;
direction: 'up' | 'down' | 'flat';
value: string | number;
tone: 'positive' | 'negative' | 'neutral';
};
semanticConfidence: 'high' | 'medium' | 'low';
dataConfidence: 'sufficient' | 'partial' | 'insufficient';
}
export interface AnalysisReport {
summary: string;
keyFindings: string[];
evidence: EvidenceItem[];
suggestions: string[];
dataScope: {
timeDescription: string;
permissionDescription: string;
metricFormulaDescription: string;
generatedAt: string;
};
}
export type FollowUp =
| { type: 'question'; label: string; prompt: string }
| { type: 'drilldown'; label: string; target: string; filters: Record<string, unknown> }
| { type: 'export'; label: string; format: 'png' | 'pdf' | 'csv' };
export interface AnalysisRequest {
question: string;
context?: {
surface: AnalysisSurface;
productId?: string;
projectId?: string;
versionId?: string;
};
}
export type AnalysisErrorCode =
| 'NO_PERMISSION'
| 'UNSUPPORTED_ANALYSIS'
| 'AMBIGUOUS_INTENT'
| 'NO_DATA'
| 'AI_UNAVAILABLE'
| 'INVALID_PLAN';
export type AnalysisResponse =
| {
ok: true;
plan: AnalysisPlan;
metricResult: MetricResult;
insight: InsightCard;
chart: UnifiedChartSpec;
report: AnalysisReport;
followUps: FollowUp[];
}
| {
ok: false;
code: AnalysisErrorCode;
message: string;
clarificationOptions?: Array<{ label: string; prompt: string }>;
dataScope?: DataScope;
};
- Step 2: Export the shared contract
Modify packages/shared/src/index.ts:
export * from './enums';
export * from './types';
export * from './agent';
export * from './analysis';
- Step 3: Run shared type-check
Run: pnpm --filter @ftb/shared type-check
Expected: command exits 0.
- Step 4: Commit
git add packages/shared/src/analysis.ts packages/shared/src/index.ts
git commit -m "feat(ai-analysis): 添加业务分析共享契约"
Task 2: Semantic Layer, Metric Catalog, and Plan Processor
Files:
- Create:
apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts - Create:
apps/server/src/modules/ai/analysis/metric-catalog.ts - Create:
apps/server/src/modules/ai/analysis/analysis-plan-processor.ts - Create:
apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts - Create:
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts - Create:
apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts
Interfaces:
-
Consumes from Task 1:
AnalysisPlan,MetricDefinition,MetricId,DimensionId,AnalysisType,TimePolicy,DataScope. -
Produces:
parseSemanticIntent(question: string): SemanticIntentgetMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | nullnormalizeAnalysisPlan(input: AnalysisPlanDraft, now?: Date): AnalysisPlanvalidateAnalysisPlan(plan: AnalysisPlan): void
-
Step 1: Write failing semantic tests
Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts:
import { parseSemanticIntent } from './analysis-semantic-layer';
describe('analysis semantic layer', () => {
it('maps busy wording to workload with high confidence', () => {
expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
concept: 'workload',
semanticConfidence: 'high',
metricId: 'department_workload',
analysisType: 'ranking',
});
});
it('maps pressure wording to workload pressure with medium confidence', () => {
expect(parseSemanticIntent('谁压力最大')).toMatchObject({
concept: 'work_pressure',
semanticConfidence: 'medium',
metricId: 'member_pending_work',
analysisType: 'ranking',
});
});
it('keeps project risk as current-state release risk', () => {
expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
concept: 'release_risk',
metricId: 'version_risk_score',
timePolicy: 'current_state',
});
});
});
- Step 2: Write failing catalog and processor tests
Create apps/server/src/modules/ai/analysis/metric-catalog.spec.ts:
import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';
describe('metric catalog', () => {
it('defines versioned active metrics used by the MVP templates', () => {
const metric = getMetricDefinition('version_risk_score');
expect(metric).toMatchObject({
metricId: 'version_risk_score',
version: 1,
status: 'active',
defaultTimePolicy: 'current_state',
});
});
it('does not expose raw chart renderer contracts', () => {
const charts = listMetricDefinitions().map((item) => item.defaultChart);
expect(charts).toContain('horizontal_bar');
expect(charts).not.toContain('echarts_option');
});
});
Create apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts:
import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';
describe('analysis plan processor', () => {
it('normalizes trend plans without a time range to last 30 days', () => {
const plan = normalizeAnalysisPlan({
metricId: 'requirement_completion_count',
analysisType: 'trend',
dimensions: ['day'],
scope: { type: 'self', userId: 'm-1' },
filters: {},
}, new Date('2026-07-08T12:00:00.000Z'));
expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
expect(plan.timeRange).toEqual({
start: '2026-06-09T00:00:00.000Z',
end: '2026-07-08T23:59:59.999Z',
policy: 'last_30_days',
});
});
it('keeps current-state risk plans without a time range', () => {
const plan = normalizeAnalysisPlan({
metricId: 'version_risk_score',
analysisType: 'ranking',
dimensions: ['version'],
scope: { type: 'managed_projects', projectIds: ['project-1'] },
filters: {},
}, new Date('2026-07-08T12:00:00.000Z'));
expect(plan.timeRange).toBeUndefined();
expect(plan.limit).toBe(10);
});
it('rejects unsupported metric dimensions', () => {
const plan = normalizeAnalysisPlan({
metricId: 'bug_severity_count',
analysisType: 'ranking',
dimensions: ['department'],
scope: { type: 'self', userId: 'm-1' },
filters: {},
});
expect(() => validateAnalysisPlan(plan)).toThrow('Unsupported dimension department for metric bug_severity_count');
});
});
Run: pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog
Expected before implementation: FAIL because files/functions do not exist.
- Step 3: Implement semantic layer
Create apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts:
import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';
export type SemanticConcept =
| 'workload'
| 'work_pressure'
| 'release_risk'
| 'delay'
| 'delivery_efficiency'
| 'quality_risk'
| 'requirement_completion'
| 'unknown';
export interface SemanticIntent {
concept: SemanticConcept;
metricId: MetricId;
analysisType: AnalysisType;
timePolicy: TimePolicy;
semanticConfidence: 'high' | 'medium' | 'low';
}
export function parseSemanticIntent(question: string): SemanticIntent {
const text = question.trim().toLowerCase();
if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
}
if (/(压力|压着|吃紧)/.test(text)) {
return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
}
if (/(忙|负载|待办|任务最多)/.test(text)) {
const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text) ? 'department_workload' : 'member_pending_work';
return intent('workload', metricId, 'ranking', 'current_state', 'high');
}
if (/(延期|逾期|超期)/.test(text)) {
return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
}
if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
}
if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
return intent('quality_risk', metricId, analysisType, metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state', 'high');
}
if (/(加班|投入|工时)/.test(text)) {
return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
}
return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
}
function intent(
concept: SemanticConcept,
metricId: MetricId,
analysisType: AnalysisType,
timePolicy: TimePolicy,
semanticConfidence: 'high' | 'medium' | 'low',
): SemanticIntent {
return { concept, metricId, analysisType, timePolicy, semanticConfidence };
}
- Step 4: Implement metric catalog
Create apps/server/src/modules/ai/analysis/metric-catalog.ts with one active version: 1 definition for every MVP metric:
import type { MetricDefinition, MetricId } from '@ftb/shared';
export const METRIC_CATALOG: MetricDefinition[] = [
{
metricId: 'version_risk_score',
version: 1,
name: '版本风险分',
description: '基于小宝风险摘要的当前版本风险排行。',
formula: 'xiaobao_risk_summaries.risk_score',
owner: 'xiaobao',
supportedDimensions: ['version', 'project', 'product'],
supportedAnalysisTypes: ['ranking', 'summary'],
defaultChart: 'horizontal_bar',
defaultDimension: 'version',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'completion_trend',
version: 1,
name: '完成趋势',
description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
formula: 'count(completed_at or terminal status updated_at) by day',
owner: 'analysis',
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'overdue_item_count',
version: 1,
name: '逾期事项数',
description: '当前超过计划结束时间且未完成的事项数量。',
formula: 'count(open items where due_at < now)',
owner: 'analysis',
supportedDimensions: ['version', 'project', 'member', 'department'],
supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'version',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'requirement_status_count',
version: 1,
name: '需求状态分布',
description: '按需求状态统计需求数量。',
formula: 'count(requirements) by status',
owner: 'requirement',
supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
defaultChart: 'donut',
defaultDimension: 'requirement_status',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'requirement_completion_count',
version: 1,
name: '需求完成数量',
description: '按时间统计进入 released 或 closed 的需求数量。',
formula: "count(requirements where status in ('released','closed')) by time bucket",
owner: 'requirement',
supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'requirement_source_count',
version: 1,
name: '需求来源/类型占比',
description: '按需求来源或类型统计需求数量。',
formula: 'count(requirements) by source_type or type',
owner: 'requirement',
supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
supportedAnalysisTypes: ['composition', 'distribution'],
defaultChart: 'donut',
defaultDimension: 'requirement_source',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'department_workload',
version: 1,
name: '部门负载',
description: '按部门统计当前未完成事项数量。',
formula: 'count(open work items grouped by user.department_id)',
owner: 'management',
supportedDimensions: ['department'],
supportedAnalysisTypes: ['ranking', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'department',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'member_pending_work',
version: 1,
name: '成员待办',
description: '按成员统计当前未完成事项数量。',
formula: 'count(open work items grouped by assignee or owner)',
owner: 'management',
supportedDimensions: ['member', 'role', 'project', 'version'],
supportedAnalysisTypes: ['ranking', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'member',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'member_effort_hours',
version: 1,
name: '成员投入工时',
description: '按成员统计工作活动、工时记录和加班投入。',
formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
owner: 'management',
supportedDimensions: ['member', 'department', 'project', 'version'],
supportedAnalysisTypes: ['ranking', 'comparison'],
defaultChart: 'horizontal_bar',
defaultDimension: 'member',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'bug_severity_count',
version: 1,
name: 'Bug 严重度分布',
description: '按严重度统计未关闭 Bug 数量。',
formula: "count(bugs where status not in ('closed','rejected')) by severity",
owner: 'quality',
supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
defaultChart: 'stacked_horizontal_bar',
defaultDimension: 'bug_severity',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'test_pass_rate',
version: 1,
name: '测试通过率',
description: '按时间统计测试通过用例占已执行用例比例。',
formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
owner: 'quality',
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'overtime_reason_hours',
version: 1,
name: '加班原因工时',
description: '按加班原因统计加班时长。',
formula: 'sum(overtime_records.hours) by reason',
owner: 'management',
supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
supportedAnalysisTypes: ['composition', 'ranking'],
defaultChart: 'donut',
defaultDimension: 'delay_reason',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'delay_rate',
version: 1,
name: '延期率',
description: '按范围统计逾期事项占全部计划事项比例。',
formula: 'overdue_count / planned_item_count',
owner: 'analysis',
supportedDimensions: ['month', 'project', 'version', 'department'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'month',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'delay_reason_count',
version: 1,
name: '延期原因数量',
description: '按原因统计延期相关需求变更或加班原因。',
formula: 'count(requirement.change_reason) + count(overtime.reason)',
owner: 'analysis',
supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
defaultChart: 'line_area',
defaultDimension: 'delay_reason',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
];
export function listMetricDefinitions(): MetricDefinition[] {
return METRIC_CATALOG.slice();
}
export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
return candidates.find((item) => item.status === 'active') ?? null;
}
- Step 5: Implement plan processor
Create apps/server/src/modules/ai/analysis/analysis-plan-processor.ts with these exports:
import { BadRequestException } from '@nestjs/common';
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';
export interface AnalysisPlanDraft {
metricId: MetricId;
metricVersion?: number;
analysisType: AnalysisType;
dimensions: DimensionId[];
scope: DataScope;
filters?: Record<string, string | number | boolean | string[] | number[]>;
timeRange?: { start: string; end: string; policy?: TimePolicy };
limit?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
const MAX_TOP_N = 20;
const DEFAULT_TOP_N = 10;
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
const metric = getMetricDefinition(input.metricId, input.metricVersion);
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
const analysisType = input.analysisType;
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
const timeRange = input.timeRange
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
: needsDefaultTime
? last30Days(now)
: undefined;
const plan: AnalysisPlan = {
metricRef: { metricId: metric.metricId, version: metric.version },
analysisType,
dimensions: input.dimensions.length > 0 ? input.dimensions : metric.defaultDimension ? [metric.defaultDimension] : [],
filters: input.filters ?? {},
scope: input.scope,
...(timeRange ? { timeRange } : {}),
limit: normalizeLimit(input.limit, analysisType),
...(input.sort ? { sort: input.sort } : {}),
};
validateAnalysisPlan(plan);
return plan;
}
export function validateAnalysisPlan(plan: AnalysisPlan): void {
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric || metric.status !== 'active') {
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
}
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
}
for (const dimension of plan.dimensions) {
if (!metric.supportedDimensions.includes(dimension)) {
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
}
}
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
}
if (plan.timeRange) {
const start = Date.parse(plan.timeRange.start);
const end = Date.parse(plan.timeRange.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
throw new BadRequestException('Invalid time range');
}
}
}
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
const value = limit ?? DEFAULT_TOP_N;
return Math.min(Math.max(1, value), MAX_TOP_N);
}
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
const end = new Date(now);
end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 29);
start.setHours(0, 0, 0, 0);
return {
start: start.toISOString(),
end: end.toISOString(),
policy: 'last_30_days',
};
}
- Step 6: Run focused backend tests
Run: pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog
Expected: PASS for the three new test files.
- Step 7: Commit
git add apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts \
apps/server/src/modules/ai/analysis/metric-catalog.ts \
apps/server/src/modules/ai/analysis/analysis-plan-processor.ts \
apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts \
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts \
apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts
git commit -m "feat(ai-analysis): 添加语义层和分析计划处理器"
Task 3: Permission Scope Resolver
Files:
- Create:
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts - Create:
apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts
Interfaces:
-
Consumes:
DataScopefrom Task 1,RbacServicefromapps/server/src/common/rbac/rbac.service.ts, PrismaService. -
Produces:
resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope>buildScopedWhere(scope: DataScope): { productId?: any; projectId?: any; versionId?: any }
-
Step 1: Write failing resolver tests
Create apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts:
import { ForbiddenException } from '@nestjs/common';
import { PermissionScopeResolver } from './permission-scope-resolver';
describe('PermissionScopeResolver', () => {
function makeResolver() {
const prisma = {
project: { findFirst: jest.fn() },
version: { findFirst: jest.fn() },
projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
};
const rbac = {
assertGlobalPermission: jest.fn(),
assertProjectRole: jest.fn(),
};
return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
}
it('returns system scope for wildcard permissions', async () => {
const { rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });
await expect(resolver.resolveAnalysisScope({
actorId: 'm-8',
permissions: ['*'],
context: { surface: 'ai_assistant' },
})).resolves.toEqual({ type: 'system', reason: 'admin' });
});
it('returns managed project scope for management permission', async () => {
const { prisma, rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);
await expect(resolver.resolveAnalysisScope({
actorId: 'm-pm',
permissions: ['management:view'],
context: { surface: 'ai_assistant' },
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
});
it('restricts version context to the requested version when the user has page access', async () => {
const { prisma, rbac, resolver } = makeResolver();
prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
rbac.assertProjectRole.mockResolvedValue({ actorId: 'm-dev', projectId: 'project-1', role: 'member', via: 'project_member' });
await expect(resolver.resolveAnalysisScope({
actorId: 'm-dev',
permissions: [],
context: { surface: 'version_detail', versionId: 'version-1' },
})).resolves.toEqual({ type: 'version', versionId: 'version-1' });
});
it('rejects missing actor id', async () => {
const { resolver } = makeResolver();
await expect(resolver.resolveAnalysisScope({
actorId: '',
permissions: [],
context: { surface: 'ai_assistant' },
})).rejects.toBeInstanceOf(ForbiddenException);
});
});
Run: pnpm --filter server test -- permission-scope-resolver
Expected before implementation: FAIL because the resolver file does not exist.
- Step 2: Implement resolver
Create apps/server/src/modules/ai/analysis/permission-scope-resolver.ts:
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { AnalysisRequest, DataScope } from '@ftb/shared';
import { RbacService } from '../../../common/rbac/rbac.service';
import { PrismaService } from '../../../prisma/prisma.service';
export interface AnalysisScopeInput {
actorId?: string;
permissions: string[];
context?: AnalysisRequest['context'];
}
@Injectable()
export class PermissionScopeResolver {
constructor(
private readonly prisma: PrismaService,
private readonly rbac: RbacService,
) {}
async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
const actorId = input.actorId?.trim();
if (!actorId) throw new ForbiddenException('Missing actor scope');
const context = input.context;
if (context?.surface === 'version_detail' && context.versionId) {
const version = await this.prisma.version.findFirst({
where: { id: context.versionId },
select: { id: true, projectId: true },
});
if (!version) throw new NotFoundException('Version not found');
if (version.projectId) {
await this.rbac.assertProjectRole({
actorId,
projectId: version.projectId,
allowedRoles: ['viewer'],
permissions: input.permissions,
});
}
return { type: 'version', versionId: version.id };
}
if (context?.surface === 'project_detail' && context.projectId) {
await this.rbac.assertProjectRole({
actorId,
projectId: context.projectId,
allowedRoles: ['viewer'],
permissions: input.permissions,
});
return { type: 'project', projectId: context.projectId };
}
if (context?.surface === 'product_detail' && context.productId) {
if (input.permissions.includes('*')) return { type: 'product', productId: context.productId };
const project = await this.prisma.project.findFirst({
where: { productId: context.productId, members: { some: { userId: actorId } } },
select: { id: true },
});
if (!project && !input.permissions.includes('product:view')) {
throw new ForbiddenException('No product analysis scope');
}
return { type: 'product', productId: context.productId };
}
if (input.permissions.includes('*')) {
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
return { type: 'system', reason: 'admin' };
}
if (input.permissions.includes('management:view')) {
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
const rows = await this.prisma.projectMember.findMany({
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
select: { projectId: true },
});
return { type: 'managed_projects', projectIds: Array.from(new Set(rows.map((row) => row.projectId))) };
}
return { type: 'self', userId: actorId };
}
}
export function buildScopedWhere(scope: DataScope) {
if (scope.type === 'product') return { productId: scope.productId };
if (scope.type === 'project') return { projectId: scope.projectId };
if (scope.type === 'version') return { versionId: scope.versionId };
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
return {};
}
- Step 3: Run focused resolver tests
Run: pnpm --filter server test -- permission-scope-resolver
Expected: PASS.
- Step 4: Commit
git add apps/server/src/modules/ai/analysis/permission-scope-resolver.ts \
apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts
git commit -m "feat(ai-analysis): 添加分析权限范围解析"
Task 4: Metric Engine MVP Queries
Files:
- Create:
apps/server/src/modules/ai/analysis/metric-engine.ts - Create:
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Interfaces:
-
Consumes: normalized
AnalysisPlan,buildScopedWhere(scope), Prisma relation models. -
Produces:
executeMetric(plan: AnalysisPlan, now?: Date): Promise<MetricResult>
-
Step 1: Write failing Metric Engine tests
Create apps/server/src/modules/ai/analysis/metric-engine.spec.ts:
import { MetricEngine } from './metric-engine';
describe('MetricEngine', () => {
function makeEngine() {
const prisma = {
xiaobaoRiskSummary: { findMany: jest.fn() },
version: { findMany: jest.fn() },
versionPlan: { findMany: jest.fn() },
devTask: { findMany: jest.fn() },
testCase: { findMany: jest.fn() },
bug: { findMany: jest.fn() },
requirement: { findMany: jest.fn() },
taskWorklog: { findMany: jest.fn() },
overtimeRecord: { findMany: jest.fn() },
user: { findMany: jest.fn() },
};
return { prisma, engine: new MetricEngine(prisma as any) };
}
it('returns version risk ranking from Xiaobao summaries', async () => {
const { prisma, engine } = makeEngine();
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
{ versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
{ versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
]);
prisma.version.findMany.mockResolvedValue([
{ id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
{ id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
]);
const result = await engine.executeMetric({
metricRef: { metricId: 'version_risk_score', version: 1 },
analysisType: 'ranking',
dimensions: ['version'],
filters: {},
scope: { type: 'project', projectId: 'project-1' },
limit: 10,
}, new Date('2026-07-08T12:00:00.000Z'));
expect(result.rows).toEqual([
{ versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
{ versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
]);
expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
});
it('groups member pending work across plans, tasks, cases, and bugs', async () => {
const { prisma, engine } = makeEngine();
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
prisma.user.findMany.mockResolvedValue([
{ id: 'm-1', name: '张三', departmentId: '研发' },
{ id: 'm-2', name: '李四', departmentId: '测试' },
]);
const result = await engine.executeMetric({
metricRef: { metricId: 'member_pending_work', version: 1 },
analysisType: 'ranking',
dimensions: ['member'],
filters: {},
scope: { type: 'version', versionId: 'ver-1' },
limit: 10,
});
expect(result.rows).toEqual([
{ memberId: 'm-1', label: '张三', value: 3 },
{ memberId: 'm-2', label: '李四', value: 1 },
]);
});
it('returns no-data evidence for empty requirement trends', async () => {
const { prisma, engine } = makeEngine();
prisma.requirement.findMany.mockResolvedValue([]);
const result = await engine.executeMetric({
metricRef: { metricId: 'requirement_completion_count', version: 1 },
analysisType: 'trend',
dimensions: ['day'],
filters: {},
scope: { type: 'self', userId: 'm-1' },
timeRange: {
start: '2026-06-09T00:00:00.000Z',
end: '2026-07-08T23:59:59.999Z',
policy: 'last_30_days',
},
});
expect(result.rows).toEqual([]);
expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
});
});
Run: pnpm --filter server test -- metric-engine
Expected before implementation: FAIL because MetricEngine does not exist.
- Step 2: Implement MetricEngine with dispatcher
Create apps/server/src/modules/ai/analysis/metric-engine.ts with:
import { Injectable } from '@nestjs/common';
import type { AnalysisPlan, EvidenceItem, MetricResult } from '@ftb/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { buildScopedWhere } from './permission-scope-resolver';
type OpenWorkItem = {
id: string;
versionId: string;
title: string;
ownerId?: string | null;
assigneeId?: string | null;
};
@Injectable()
export class MetricEngine {
constructor(private readonly prisma: PrismaService) {}
async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
}
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const scopedWhere = buildScopedWhere(plan.scope);
const versionWhere = 'versionId' in scopedWhere ? { id: scopedWhere.versionId } : scopedWhere;
const versions = await this.prisma.version.findMany({ where: versionWhere, select: { id: true, name: true, projectId: true, productId: true } });
const versionById = new Map(versions.map((version: any) => [version.id, version]));
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versions.map((version: any) => version.id) } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
take: plan.limit ?? 10,
});
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '版本', type: 'string' },
{ id: 'value', label: '风险分', type: 'number' },
{ id: 'riskLevel', label: '风险等级', type: 'string' },
],
rows: rows.map((row: any) => ({
versionId: row.versionId,
label: versionById.get(row.versionId)?.name ?? row.versionId,
value: row.riskScore,
riskLevel: row.riskLevel,
})),
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = buildScopedWhere(plan.scope);
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' } } }),
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' } } }),
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] } } }),
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } } }),
]);
const counts = new Map<string, number>();
for (const item of [...plans.map((p: any) => ({ ...p, assigneeId: p.ownerId })), ...devTasks, ...testCases, ...bugs] as OpenWorkItem[]) {
const memberId = item.assigneeId ?? item.ownerId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(counts.keys()) } }, select: { id: true, name: true, departmentId: true } });
const userById = new Map(users.map((user: any) => [user.id, user]));
const rows = Array.from(counts.entries())
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
.slice(0, plan.limit ?? 10);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '成员', type: 'string' },
{ id: 'value', label: '待办数', type: 'number' },
],
rows,
evidence: [{ label: '未完成事项', value: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), sourceDomain: 'dev_task' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const memberResult = await this.memberPendingWork({ ...plan, metricRef: { metricId: 'member_pending_work', version: 1 }, dimensions: ['member'] }, now);
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
const users = await this.prisma.user.findMany({ where: { id: { in: memberIds } }, select: { id: true, departmentId: true } });
const departmentByMember = new Map(users.map((user: any) => [user.id, user.departmentId || '未分部门']));
const counts = new Map<string, number>();
for (const row of memberResult.rows) {
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
}
return rowsResult(plan, now, '部门', '待办数', Array.from(counts.entries()).map(([label, value]) => ({ label, value })), 'dev_task');
}
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = buildScopedWhere(plan.scope);
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' }, expectedEndAt: { lt: now } } }),
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' }, expectedEndAt: { lt: now } } }),
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] }, plannedEndAt: { lt: now } } }),
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } } }),
]);
const byVersion = new Map<string, number>();
for (const item of [...plans, ...devTasks, ...testCases, ...bugs] as any[]) {
if (!item.versionId) continue;
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
}
return rowsResult(plan, now, '版本', '逾期数', Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })), 'version');
}
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.requirement.findMany({ where: buildScopedWhere(plan.scope), select: { status: true } });
return countRows(plan, now, rows.map((row: any) => row.status || 'unknown'), '状态', '数量', 'requirement');
}
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['released', 'closed'] } };
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } });
return trendRows(plan, now, rows.map((row: any) => row.updatedAt), '完成需求', 'requirement');
}
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } });
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
return countRows(plan, now, rows.map((row: any) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
}
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) {
where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
}
const [worklogs, overtime] = await Promise.all([
this.prisma.taskWorklog.findMany({ where, select: { userId: true, hours: true } }),
this.prisma.overtimeRecord.findMany({ where, select: { userId: true, hours: true } }),
]);
const hours = new Map<string, number>();
for (const row of [...worklogs, ...overtime] as any[]) {
if (!row.userId) continue;
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
}
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(hours.keys()) } }, select: { id: true, name: true } });
const userById = new Map(users.map((user: any) => [user.id, user.name]));
return rowsResult(plan, now, '成员', '小时', Array.from(hours.entries()).map(([memberId, value]) => ({ memberId, label: userById.get(memberId) ?? memberId, value })), 'task_worklog');
}
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.bug.findMany({ where: { ...buildScopedWhere(plan.scope), status: { notIn: ['closed', 'rejected'] } }, select: { severity: true } });
return countRows(plan, now, rows.map((row: any) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
}
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['passed', 'failed', 'blocked'] } };
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.testCase.findMany({ where, select: { status: true, updatedAt: true } });
const buckets = bucketDates(rows.map((row: any) => row.updatedAt));
const source = Array.from(buckets.entries()).map(([label]) => {
const sameDay = rows.filter((row: any) => dayKey(row.updatedAt) === label);
const passed = sameDay.filter((row: any) => row.status === 'passed').length;
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
});
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
}
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where: any = buildScopedWhere(plan.scope);
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
const rows = await this.prisma.overtimeRecord.findMany({ where, select: { reason: true, hours: true } });
const hours = new Map<string, number>();
for (const row of rows as any[]) {
const reason = row.reason || '未填写';
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
}
return rowsResult(plan, now, '原因', '小时', Array.from(hours.entries()).map(([label, value]) => ({ label, value })), 'overtime');
}
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const requirementPlan = { ...plan, metricRef: { metricId: 'requirement_completion_count' as const, version: 1 }, dimensions: ['day' as const] };
return this.requirementCompletionTrend(requirementPlan, now);
}
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const overdue = await this.overdueItemCount({ ...plan, metricRef: { metricId: 'overdue_item_count', version: 1 }, dimensions: ['version'], analysisType: 'ranking' }, now);
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
return { ...overdue, metricRef: plan.metricRef, rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })) };
}
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
return this.overtimeReasonHours({ ...plan, metricRef: { metricId: 'overtime_reason_hours', version: 1 }, dimensions: ['delay_reason'] }, now);
}
}
function rowsResult(plan: AnalysisPlan, now: Date, labelName: string, valueName: string, rows: Array<Record<string, string | number | null>>, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const sorted = rows.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0)).slice(0, plan.limit ?? rows.length);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: labelName, type: 'string' },
{ id: 'value', label: valueName, type: 'number' },
],
rows: sorted,
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
function countRows(plan: AnalysisPlan, now: Date, labels: string[], labelName: string, valueName: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const counts = new Map<string, number>();
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
return rowsResult(plan, now, labelName, valueName, Array.from(counts.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}
function trendRows(plan: AnalysisPlan, now: Date, dates: Date[], valueLabel: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
const buckets = bucketDates(dates);
return rowsResult(plan, now, '日期', valueLabel, Array.from(buckets.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
}
function bucketDates(dates: Date[]) {
const buckets = new Map<string, number>();
for (const date of dates) {
const key = dayKey(date);
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
return buckets;
}
function dayKey(value: Date | string) {
return new Date(value).toISOString().slice(0, 10);
}
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [],
rows: [],
evidence,
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
- Step 3: Run focused engine tests
Run: pnpm --filter server test -- metric-engine
Expected: PASS.
- Step 4: Commit
git add apps/server/src/modules/ai/analysis/metric-engine.ts \
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
git commit -m "feat(ai-analysis): 实现业务分析指标引擎"
Task 5: Response Builders, AI Planning, and /ai/analysis
Files:
- Create:
apps/server/src/modules/ai/dto/analysis.dto.ts - Create:
apps/server/src/modules/ai/prompts/analysis-plan.ts - Create:
apps/server/src/modules/ai/prompts/analysis-report.ts - Create:
apps/server/src/modules/ai/analysis/analysis-strategy.ts - Create:
apps/server/src/modules/ai/analysis/chart-spec-builder.ts - Create:
apps/server/src/modules/ai/analysis/insight-engine.ts - Create:
apps/server/src/modules/ai/analysis/report-builder.ts - Create:
apps/server/src/modules/ai/analysis/follow-up-builder.ts - Create:
apps/server/src/modules/ai/analysis/business-analysis.service.ts - Create:
apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts - Modify:
apps/server/src/modules/ai/ai.controller.ts - Modify:
apps/server/src/modules/ai/ai.module.ts
Interfaces:
-
Consumes: Tasks 1-4.
-
Produces:
POST /api/v1/ai/analysisBusinessAnalysisService.analyze(req, user, permissions)buildUnifiedChartSpec(result, metric)buildInsightCard(result, semanticConfidence)buildAnalysisReport(result, insight, metric)buildFollowUps(result, plan)
-
Step 1: Write failing orchestration tests
Create apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts:
import { BusinessAnalysisService } from './business-analysis.service';
describe('BusinessAnalysisService', () => {
function makeService() {
const scopeResolver = { resolveAnalysisScope: jest.fn().mockResolvedValue({ type: 'version', versionId: 'ver-1' }) };
const metricEngine = {
executeMetric: jest.fn().mockResolvedValue({
metricRef: { metricId: 'version_risk_score', version: 1 },
analysisType: 'ranking',
columns: [{ id: 'label', label: '版本', type: 'string' }],
rows: [{ label: 'V1', value: 88 }],
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
dataScope: { type: 'version', versionId: 'ver-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
}),
};
const reportBuilder = { build: jest.fn().mockResolvedValue({
summary: 'V1 风险较高。',
keyFindings: ['风险分 88。'],
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
suggestions: ['优先处理阻塞和严重 Bug。'],
dataScope: {
timeDescription: '当前状态',
permissionDescription: '当前版本',
metricFormulaDescription: '小宝风险分',
generatedAt: '2026-07-08T12:00:00.000Z',
},
}) };
const service = new BusinessAnalysisService(scopeResolver as any, metricEngine as any, reportBuilder as any);
return { service, scopeResolver, metricEngine, reportBuilder };
}
it('returns insight, chart, report, evidence, and follow-ups', async () => {
const { service } = makeService();
const result = await service.analyze({
question: '这个版本风险怎么样',
context: { surface: 'version_detail', versionId: 'ver-1' },
}, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z'));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.chart.kind).toBe('horizontal_bar');
expect(result.insight.summary).toContain('V1');
expect(result.report.summary).toContain('风险');
expect(result.followUps.some((item) => item.type === 'question')).toBe(true);
}
});
it('returns NO_DATA when metric result has no rows', async () => {
const { service, metricEngine } = makeService();
metricEngine.executeMetric.mockResolvedValueOnce({
metricRef: { metricId: 'requirement_completion_count', version: 1 },
analysisType: 'trend',
columns: [],
rows: [],
evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
dataScope: { type: 'self', userId: 'm-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
});
await expect(service.analyze({ question: '需求完成趋势' }, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z')))
.resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
});
});
Run: pnpm --filter server test -- business-analysis.service
Expected before implementation: FAIL because service/builders do not exist.
- Step 2: Add DTO and controller endpoint
Create apps/server/src/modules/ai/dto/analysis.dto.ts:
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class AnalysisContextDto {
@IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
}
export class AnalysisDto {
@IsString()
question!: string;
@IsOptional()
@ValidateNested()
@Type(() => AnalysisContextDto)
context?: AnalysisContextDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
Modify apps/server/src/modules/ai/ai.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';
import { CurrentUser } from '../../common/auth/current-user.decorator';
import { AiService } from './ai.service';
import { BusinessAnalysisService } from './analysis/business-analysis.service';
import { AnalysisDto } from './dto/analysis.dto';
import { DecomposeDto } from './dto/decompose.dto';
import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type {
AgentDecomposeResponse,
AgentDecomposeError,
AgentRiskInterpretResponse,
AgentRiskInterpretError,
AnalysisResponse,
} from '@ftb/shared';
import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.service';
@Controller('ai')
export class AiController {
constructor(
private readonly aiService: AiService,
private readonly businessAnalysisService: BusinessAnalysisService,
) {}
@Post('decompose')
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
@Post('risk-interpret')
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto);
}
@Post('analysis')
async analyze(
@Body() dto: AnalysisDto,
@CurrentUser() user: ResolvedCurrentUser | null,
): Promise<AnalysisResponse> {
return this.businessAnalysisService.analyze(dto, {
id: user?.id ?? '',
permissions: dto.permissions ?? [],
});
}
}
Security boundary: dto.permissions is only the caller's current role permission assertion, matching the existing management/governance API pattern. It never widens data by itself. PermissionScopeResolver still verifies project membership through RbacService.assertProjectRole, verifies wildcard/global access through RbacService.assertGlobalPermission, and returns the narrowest DataScope for the request context.
- Step 3: Add AI Planning prompts and deterministic builders
Create apps/server/src/modules/ai/prompts/analysis-plan.ts:
export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';
export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析计划助手。
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL,不能绕过权限。
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
当问题无法映射到已暴露能力时,返回 clarificationOptions,不要编造指标。
`.trim();
export const ANALYSIS_PLAN_TOOL_DESCRIPTION = 'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';
export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['metricId', 'analysisType', 'dimensions', 'filters'],
properties: {
metricId: {
type: 'string',
enum: [
'version_risk_score',
'completion_trend',
'overdue_item_count',
'requirement_status_count',
'requirement_completion_count',
'requirement_source_count',
'department_workload',
'member_pending_work',
'member_effort_hours',
'bug_severity_count',
'test_pass_rate',
'overtime_reason_hours',
'delay_rate',
'delay_reason_count',
],
},
metricVersion: { type: 'number' },
analysisType: {
type: 'string',
enum: ['ranking', 'trend', 'comparison', 'distribution', 'composition', 'correlation', 'breakdown', 'summary'],
},
dimensions: {
type: 'array',
items: {
type: 'string',
enum: [
'product',
'project',
'version',
'requirement_status',
'requirement_type',
'requirement_source',
'department',
'member',
'role',
'month',
'week',
'day',
'bug_severity',
'bug_status',
'test_status',
'delay_reason',
],
},
},
filters: { type: 'object', additionalProperties: true },
timeRange: {
type: 'object',
additionalProperties: false,
required: ['start', 'end'],
properties: {
start: { type: 'string' },
end: { type: 'string' },
policy: { type: 'string', enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'] },
},
},
limit: { type: 'number', minimum: 1, maximum: 20 },
sort: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['field', 'direction'],
properties: {
field: { type: 'string' },
direction: { type: 'string', enum: ['asc', 'desc'] },
},
},
},
},
} as const;
Create apps/server/src/modules/ai/prompts/analysis-report.ts:
export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';
export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析报告助手。
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
`.trim();
export const ANALYSIS_REPORT_TOOL_DESCRIPTION = 'Write a structured business analysis report from deterministic metric results without inventing facts.';
export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['summary', 'keyFindings', 'suggestions'],
properties: {
summary: { type: 'string' },
keyFindings: { type: 'array', items: { type: 'string' } },
suggestions: { type: 'array', items: { type: 'string' } },
},
} as const;
Create these builder files with deterministic outputs first:
apps/server/src/modules/ai/analysis/chart-spec-builder.ts:
import type { MetricDefinition, MetricResult, UnifiedChartSpec } from '@ftb/shared';
export function buildUnifiedChartSpec(result: MetricResult, metric: MetricDefinition): UnifiedChartSpec {
const labelField = result.columns.find((column) => column.type === 'string')?.id ?? 'label';
const valueField = result.columns.find((column) => column.type === 'number' || column.type === 'percent')?.id ?? 'value';
return {
kind: metric.defaultChart,
title: metric.name,
subtitle: metric.description,
dataset: {
source: result.rows,
label: labelField,
value: valueField,
x: metric.defaultChart === 'line_area' ? labelField : undefined,
y: metric.defaultChart === 'line_area' ? valueField : undefined,
},
encoding: {
x: metric.defaultChart === 'line_area' ? { field: labelField, label: result.columns.find((column) => column.id === labelField)?.label ?? labelField } : undefined,
y: metric.defaultChart === 'line_area' ? { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField } : undefined,
value: { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField },
color: { mode: result.metricRef.metricId === 'version_risk_score' ? 'risk' : 'single' },
},
annotations: [],
stylePreset: 'apple_vision_light',
};
}
apps/server/src/modules/ai/analysis/insight-engine.ts:
import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
export function buildInsightCard(
result: MetricResult,
metric: MetricDefinition,
semanticConfidence: InsightCard['semanticConfidence'],
): InsightCard {
const top = result.rows[0];
const value = top?.value ?? result.totals?.value ?? 0;
const label = String(top?.label ?? metric.name);
const dataConfidence: InsightCard['dataConfidence'] = result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
return {
summary: result.rows.length === 0 ? `${metric.name}暂无可分析数据。` : `${label}在${metric.name}中最突出。`,
primaryValue: { label: metric.name, value },
semanticConfidence,
dataConfidence,
};
}
apps/server/src/modules/ai/analysis/report-builder.ts:
import { Injectable } from '@nestjs/common';
import type { AnalysisReport, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
@Injectable()
export class AnalysisReportBuilder {
async build(result: MetricResult, insight: InsightCard, metric: MetricDefinition): Promise<AnalysisReport> {
return {
summary: insight.summary,
keyFindings: result.rows.slice(0, 4).map((row) => `${row.label ?? '项目'}:${row.value ?? 0}`),
evidence: result.evidence,
suggestions: result.rows.length > 0 ? ['优先查看排名靠前的对象,并进入明细确认原因。'] : ['调整时间范围或切换分析维度。'],
dataScope: {
timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态',
permissionDescription: describeScope(result.dataScope),
metricFormulaDescription: metric.formula,
generatedAt: result.generatedAt,
},
};
}
}
function resultHasTime(result: MetricResult): boolean {
return result.columns.some((column) => column.type === 'date');
}
function describeScope(scope: MetricResult['dataScope']) {
if (scope.type === 'system') return '系统管理范围';
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
if (scope.type === 'self') return '与当前用户相关的数据';
if (scope.type === 'product') return `产品范围:${scope.productId}`;
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
return `版本范围:${scope.versionId}`;
}
apps/server/src/modules/ai/analysis/follow-up-builder.ts:
import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';
export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
const top = result.rows[0];
const baseQuestion = top?.label ? `为什么${top.label}最高?` : '换一个维度继续分析';
return [
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
{ type: 'drilldown', label: '查看明细', target: plan.dimensions[0] ?? 'analysis', filters: plan.filters },
{ type: 'export', label: '导出报告', format: 'pdf' },
];
}
- Step 4: Implement strategy and service
Create apps/server/src/modules/ai/analysis/analysis-strategy.ts:
import type { AnalysisRequest, AnalysisPlan } from '@ftb/shared';
import { parseSemanticIntent } from './analysis-semantic-layer';
import { normalizeAnalysisPlan } from './analysis-plan-processor';
export function createAnalysisPlanFromQuestion(
request: AnalysisRequest,
scope: AnalysisPlan['scope'],
now = new Date(),
) {
const semantic = parseSemanticIntent(request.question);
const dimensions = inferDimensions(request.question, semantic.metricId);
const plan = normalizeAnalysisPlan({
metricId: semantic.metricId,
analysisType: semantic.analysisType,
dimensions,
scope,
filters: {},
}, now);
return { semantic, plan };
}
function inferDimensions(question: string, metricId: AnalysisPlan['metricRef']['metricId']): AnalysisPlan['dimensions'] {
if (/部门/.test(question)) return ['department'];
if (/成员|谁|负责人/.test(question)) return ['member'];
if (/产品/.test(question)) return ['product'];
if (/项目/.test(question)) return ['project'];
if (/月|月份/.test(question)) return ['month'];
if (/周/.test(question)) return ['week'];
if (metricId === 'version_risk_score') return ['version'];
if (metricId === 'bug_severity_count') return ['bug_severity'];
if (metricId === 'requirement_status_count') return ['requirement_status'];
if (metricId === 'requirement_source_count') return ['requirement_source'];
return ['version'];
}
Create apps/server/src/modules/ai/analysis/business-analysis.service.ts:
import { Injectable } from '@nestjs/common';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';
import { PermissionScopeResolver } from './permission-scope-resolver';
import { MetricEngine } from './metric-engine';
import { AnalysisReportBuilder } from './report-builder';
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
import { buildUnifiedChartSpec } from './chart-spec-builder';
import { buildInsightCard } from './insight-engine';
import { buildFollowUps } from './follow-up-builder';
export interface AnalysisActor {
id: string;
permissions: string[];
}
@Injectable()
export class BusinessAnalysisService {
constructor(
private readonly scopeResolver: PermissionScopeResolver,
private readonly metricEngine: MetricEngine,
private readonly reportBuilder: AnalysisReportBuilder,
) {}
async analyze(request: AnalysisRequest, actor: AnalysisActor, now = new Date()): Promise<AnalysisResponse> {
const question = request.question.trim();
if (!question) {
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
}
const scope = await this.scopeResolver.resolveAnalysisScope({
actorId: actor.id,
permissions: actor.permissions,
context: request.context,
});
const { semantic, plan } = createAnalysisPlanFromQuestion(request, scope, now);
if (semantic.semanticConfidence === 'low') {
return {
ok: false,
code: 'AMBIGUOUS_INTENT',
message: '这个问题有多种理解,请选择一个分析方向。',
clarificationOptions: [
{ label: '成员负载', prompt: '分析成员待办排行' },
{ label: '版本风险', prompt: '分析版本风险排行' },
],
dataScope: scope,
};
}
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric) return { ok: false, code: 'UNSUPPORTED_ANALYSIS', message: '当前指标不在分析目录中。', dataScope: scope };
const metricResult = await this.metricEngine.executeMetric(plan, now);
if (metricResult.rows.length === 0) {
return {
ok: false,
code: 'NO_DATA',
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
dataScope: scope,
};
}
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
const chart = buildUnifiedChartSpec(metricResult, metric);
const report = await this.reportBuilder.build(metricResult, insight, metric);
const followUps = buildFollowUps(metricResult, plan);
return { ok: true, plan, metricResult, insight, chart, report, followUps };
}
}
- Step 5: Wire module providers
Modify apps/server/src/modules/ai/ai.module.ts:
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiGatewayService } from './ai-gateway.service';
import { ConfigModule } from '../config/config.module';
import { BusinessAnalysisService } from './analysis/business-analysis.service';
import { MetricEngine } from './analysis/metric-engine';
import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
import { AnalysisReportBuilder } from './analysis/report-builder';
@Module({
imports: [ConfigModule, CommonDomainModule],
controllers: [AiController],
providers: [
AiService,
AiGatewayService,
BusinessAnalysisService,
PermissionScopeResolver,
MetricEngine,
AnalysisReportBuilder,
],
exports: [AiService],
})
export class AiModule {}
- Step 6: Run backend focused tests
Run: pnpm --filter server test -- business-analysis.service ai.service
Expected: PASS.
- Step 7: Run server type-check
Run: pnpm --filter server type-check
Expected: command exits 0.
- Step 8: Commit
git add apps/server/src/modules/ai
git commit -m "feat(ai-analysis): 接入业务分析接口"
Task 6: Frontend Analysis API and ECharts Renderer
Files:
- Modify:
apps/web/package.json - Modify:
pnpm-lock.yaml - Create:
apps/web/lib/analysis-api.ts - Create:
apps/web/lib/analysis-api.test.ts - Create:
apps/web/lib/analysis-chart-renderer.ts - Create:
apps/web/lib/analysis-chart-renderer.test.ts - Create:
apps/web/components/analysis/AnalysisChart.tsx
Interfaces:
-
Consumes:
AnalysisRequest,AnalysisResponse,UnifiedChartSpecfrom@ftb/shared. -
Produces:
requestAnalysis(request: AnalysisRequest, permissions?: string[]): Promise<AnalysisResponse>toEChartsOption(spec: UnifiedChartSpec): EChartsOption<AnalysisChart spec={chart} />
-
Step 1: Add ECharts dependencies
Run: pnpm add --filter web echarts echarts-for-react
Expected: apps/web/package.json includes echarts and echarts-for-react; pnpm-lock.yaml updates.
- Step 2: Write failing renderer tests
Create apps/web/lib/analysis-chart-renderer.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import { toEChartsOption } from './analysis-chart-renderer';
import type { UnifiedChartSpec } from '@ftb/shared';
test('toEChartsOption renders line_area with smooth line and area gradient', () => {
const spec: UnifiedChartSpec = {
kind: 'line_area',
title: '需求完成趋势',
dataset: { source: [{ label: '2026-07-01', value: 3 }], x: 'label', y: 'value' },
encoding: {
x: { field: 'label', label: '日期' },
y: { field: 'value', label: '完成数' },
value: { field: 'value', label: '完成数' },
color: { mode: 'single' },
},
annotations: [{ type: 'peak', label: '峰值', field: 'value', value: 3 }],
stylePreset: 'apple_vision_light',
};
const option: any = toEChartsOption(spec);
assert.equal(option.series[0].type, 'line');
assert.equal(option.series[0].smooth, true);
assert.ok(option.series[0].areaStyle);
assert.equal(option.xAxis.show, true);
assert.equal(option.yAxis.splitLine.show, false);
});
test('toEChartsOption renders horizontal_bar with rounded bars and single color', () => {
const option: any = toEChartsOption({
kind: 'horizontal_bar',
title: '部门负载',
dataset: { source: [{ label: '研发', value: 8 }], label: 'label', value: 'value' },
encoding: {
value: { field: 'value', label: '待办数' },
color: { mode: 'single' },
},
stylePreset: 'apple_vision_light',
});
assert.equal(option.series[0].type, 'bar');
assert.deepEqual(option.series[0].itemStyle.borderRadius, [0, 8, 8, 0]);
assert.equal(option.color.length, 1);
});
Create apps/web/lib/analysis-api.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAnalysis } from './analysis-api';
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
test('requestAnalysis posts to /ai/analysis', async () => {
const originalFetch = globalThis.fetch;
const calls: string[] = [];
const apiBase = resolveApiBase();
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response(JSON.stringify(calls.length === 1 ? {} : { ok: false, code: 'NO_DATA', message: 'no rows' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;
try {
__resetApiAvailabilityForTests();
const result = await requestAnalysis({ question: '哪个部门最忙', context: { surface: 'ai_assistant' } }, ['management:view']);
assert.equal(result.ok, false);
assert.deepEqual(calls, [`${apiBase}/config/ai`, `${apiBase}/ai/analysis`]);
} finally {
globalThis.fetch = originalFetch;
}
});
Run: pnpm --filter web test -- analysis-chart-renderer analysis-api
Expected before implementation: FAIL because files/functions do not exist.
- Step 3: Implement analysis API client
Create apps/web/lib/analysis-api.ts:
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { api } from './api';
export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise<AnalysisResponse> {
return api.post<AnalysisResponse>('/ai/analysis', { ...request, permissions });
}
- Step 4: Implement ChartSpec renderer
Create apps/web/lib/analysis-chart-renderer.ts:
import type { UnifiedChartSpec } from '@ftb/shared';
import type { EChartsOption } from 'echarts';
const ACCENT = '#0f172a';
const MUTED = '#94a3b8';
const RISK = '#f97316';
export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
if (spec.kind === 'line_area') return lineAreaOption(spec);
if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
if (spec.kind === 'donut') return donutOption(spec);
return numberCardFallbackOption(spec);
}
function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
return {
color: [ACCENT],
grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
xAxis: {
type: 'category',
show: true,
boundaryGap: false,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: MUTED, fontSize: 11 },
data: spec.dataset.source.map((row) => row[xField]),
},
yAxis: {
type: 'value',
show: true,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [{
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 7,
data: spec.dataset.source.map((row) => row[yField]),
lineStyle: { width: 3 },
areaStyle: { opacity: 0.14 },
markPoint: buildMarkPoints(spec),
}],
};
}
function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
const labelField = spec.dataset.label ?? 'label';
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
const rows = spec.dataset.source.slice().reverse();
return {
color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
xAxis: { type: 'value', show: false },
yAxis: {
type: 'category',
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#334155', fontSize: 12 },
data: rows.map((row) => row[labelField]),
},
series: [{
type: 'bar',
data: rows.map((row) => row[valueField]),
barWidth: 12,
itemStyle: { borderRadius: [0, 8, 8, 0] },
label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
animationDuration: 520,
}],
};
}
function donutOption(spec: UnifiedChartSpec): EChartsOption {
const labelField = spec.dataset.label ?? 'label';
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
return {
color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
series: [{
type: 'pie',
radius: ['62%', '82%'],
avoidLabelOverlap: true,
label: { color: '#334155', fontSize: 11 },
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
data: spec.dataset.source.map((row) => ({ name: row[labelField], value: row[valueField] })),
}],
};
}
function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
}
function buildMarkPoints(spec: UnifiedChartSpec) {
if (!spec.annotations?.length) return undefined;
return {
symbolSize: 42,
label: { fontSize: 10 },
data: spec.annotations.map((item) => ({ type: item.type === 'peak' ? 'max' : undefined, name: item.label, value: item.value })),
};
}
- Step 5: Implement AnalysisChart component
Create apps/web/components/analysis/AnalysisChart.tsx:
'use client';
import dynamic from 'next/dynamic';
import type { UnifiedChartSpec } from '@ftb/shared';
import { toEChartsOption } from '@/lib/analysis-chart-renderer';
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
export function AnalysisChart({ spec }: { spec: UnifiedChartSpec }) {
return (
<div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<div className="mb-3">
<h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
{spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
</div>
<ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
</div>
);
}
- Step 6: Run frontend focused tests
Run: pnpm --filter web test -- analysis-chart-renderer analysis-api
Expected: PASS.
- Step 7: Commit
git add apps/web/package.json pnpm-lock.yaml \
apps/web/lib/analysis-api.ts apps/web/lib/analysis-api.test.ts \
apps/web/lib/analysis-chart-renderer.ts apps/web/lib/analysis-chart-renderer.test.ts \
apps/web/components/analysis/AnalysisChart.tsx
git commit -m "feat(ai-analysis): 添加图表渲染和分析API客户端"
Task 7: Analysis Result Components and AI Assistant Conversation
Files:
- Create:
apps/web/components/analysis/InsightCard.tsx - Create:
apps/web/components/analysis/AnalysisReport.tsx - Create:
apps/web/components/analysis/EvidenceList.tsx - Create:
apps/web/components/analysis/FollowUpActions.tsx - Create:
apps/web/components/analysis/AnalysisResultBlock.tsx - Modify:
apps/web/app/wenfan-xiaobao/page.tsx - Test: existing
apps/web/lib/wenfan-xiaobao-ui.test.tspluspnpm --filter web type-check
Interfaces:
-
Consumes:
AnalysisResponsefrom Task 1,requestAnalysisandAnalysisChartfrom Task 6. -
Produces: AI Assistant messages of type
analysis,analysis_error, and existing help fallback. -
Step 1: Write component files
Create apps/web/components/analysis/InsightCard.tsx:
import type { InsightCard as InsightCardData } from '@ftb/shared';
export function InsightCard({ insight }: { insight: InsightCardData }) {
return (
<section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
{insight.primaryValue && (
<div className="mb-3">
<div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">{insight.primaryValue.value}{insight.primaryValue.unit ?? ''}</div>
<div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
</div>
)}
<p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
<p className="mt-3 text-[12px] text-[#64748b]">
语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
</p>
)}
</section>
);
}
function confidenceLabel(value: InsightCardData['semanticConfidence']) {
return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
}
function dataLabel(value: InsightCardData['dataConfidence']) {
return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
}
Create apps/web/components/analysis/EvidenceList.tsx:
import type { EvidenceItem } from '@ftb/shared';
export function EvidenceList({ items }: { items: EvidenceItem[] }) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{items.map((item, index) => (
<button
key={`${item.label}-${index}`}
type="button"
className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
disabled={!item.drilldown}
title={item.sourceLabel ?? item.sourceDomain}
>
<span className="text-[#64748b]">{item.label}</span>
<span className="ml-1 font-semibold text-[#0f172a]">{item.value}{item.unit ?? ''}</span>
</button>
))}
</div>
);
}
Create apps/web/components/analysis/AnalysisReport.tsx:
import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
import { EvidenceList } from './EvidenceList';
export function AnalysisReport({ report }: { report: AnalysisReportData }) {
return (
<section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
<p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
<ReportSection title="关键发现" items={report.keyFindings} />
<div className="mt-4">
<p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
<EvidenceList items={report.evidence} />
</div>
<ReportSection title="建议动作" items={report.suggestions} />
<div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
<p>{report.dataScope.timeDescription}</p>
<p>{report.dataScope.permissionDescription}</p>
<p>{report.dataScope.metricFormulaDescription}</p>
</div>
</section>
);
}
function ReportSection({ title, items }: { title: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="mt-4">
<p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
<ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
{items.map((item) => <li key={item}>• {item}</li>)}
</ul>
</div>
);
}
Create apps/web/components/analysis/FollowUpActions.tsx:
import type { FollowUp } from '@ftb/shared';
export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
if (followUps.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{followUps.map((item) => (
<button
key={`${item.type}-${item.label}`}
type="button"
onClick={() => item.type === 'question' ? onAsk(item.prompt) : undefined}
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm hover:bg-white"
>
{item.label}
</button>
))}
</div>
);
}
Create apps/web/components/analysis/AnalysisResultBlock.tsx:
import type { AnalysisResponse } from '@ftb/shared';
import { AnalysisChart } from './AnalysisChart';
import { AnalysisReport } from './AnalysisReport';
import { FollowUpActions } from './FollowUpActions';
import { InsightCard } from './InsightCard';
export function AnalysisResultBlock({ response, onAsk }: { response: AnalysisResponse; onAsk: (prompt: string) => void }) {
if (!response.ok) {
return (
<div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm">
<p className="font-medium text-[#0f172a]">{response.message}</p>
{response.clarificationOptions && (
<div className="mt-3 flex flex-wrap gap-2">
{response.clarificationOptions.map((option) => (
<button key={option.prompt} type="button" onClick={() => onAsk(option.prompt)} className="rounded-full border px-3 py-2 text-[13px] hover:bg-[#f8fafc]">
{option.label}
</button>
))}
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<InsightCard insight={response.insight} />
<AnalysisChart spec={response.chart} />
<AnalysisReport report={response.report} />
<FollowUpActions followUps={response.followUps} onAsk={onAsk} />
</div>
);
}
- Step 2: Modify AI Assistant page message model
In apps/web/app/wenfan-xiaobao/page.tsx, add imports:
import type { AnalysisResponse } from '@ftb/shared';
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
import { requestAnalysis } from '@/lib/analysis-api';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
Add current role permissions inside WenfanXiaobaoPage:
const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
Extend ChatMessage union:
| {
id: string;
role: 'assistant';
type: 'analysis';
response: AnalysisResponse;
}
Update INITIAL_MESSAGES[0].content:
content:
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
Change askQuestion to async and call analysis first:
async function askQuestion(rawQuestion: string) {
const question = rawQuestion.trim();
if (!question) return;
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
type: 'text',
content: question,
};
const optimisticMessages = [...messages, userMessage];
setMessages(optimisticMessages);
setInput('');
try {
const analysis = await requestAnalysis(
{ question, context: { surface: 'ai_assistant' } },
currentPermissions,
);
const assistantMessage: ChatMessage = {
id: `analysis-${Date.now()}`,
role: 'assistant',
type: 'analysis',
response: analysis,
};
const nextMessages = [...optimisticMessages, assistantMessage];
setMessages(nextMessages);
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
return;
} catch {
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
const nextUserQuestionCount = userQuestionCount + 1;
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
const assistantMessage: ChatMessage =
results.length > 0
? { id: `article-${Date.now()}`, role: 'assistant', type: 'article', result: results[0] }
: {
id: `fallback-${Date.now()}`,
role: 'assistant',
type: 'fallback',
content: getFallbackHelpMessage(showFallbackSuggestions),
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
};
const nextMessages = [...optimisticMessages, assistantMessage];
setMessages(nextMessages);
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
}
}
Update form handlers to call async function without awaiting:
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void askQuestion(input);
}
In MessageRow, render analysis:
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
- Step 3: Run frontend type-check
Run: pnpm --filter web type-check
Expected: command exits 0.
- Step 4: Run focused frontend tests
Run: pnpm --filter web test -- analysis-api analysis-chart-renderer wenfan-help-search
Expected: PASS.
- Step 5: Commit
git add apps/web/components/analysis apps/web/app/wenfan-xiaobao/page.tsx
git commit -m "feat(ai-analysis): 在AI助手展示业务分析结果"
Task 8: Product, Project, and Version Context Entrypoints
Files:
- Create:
apps/web/components/analysis/AnalysisEntryButton.tsx - Create:
apps/web/components/analysis/AnalysisContextDrawer.tsx - Modify:
apps/web/app/products/[id]/page.tsx - Modify:
apps/web/app/projects/[id]/page.tsx - Modify:
apps/web/app/versions/[id]/page.tsx
Interfaces:
-
Consumes:
requestAnalysis,AnalysisResultBlock. -
Produces: context-aware analysis drawer on product, project, and version detail pages.
-
Step 1: Create context drawer components
Create apps/web/components/analysis/AnalysisEntryButton.tsx:
'use client';
import { BarChart3, Sparkles } from 'lucide-react';
export function AnalysisEntryButton({ onClick, compact = false }: { onClick: () => void; compact?: boolean }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 py-1.5 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur hover:bg-white"
title="智能分析"
>
{compact ? <BarChart3 className="h-3.5 w-3.5" /> : <Sparkles className="h-3.5 w-3.5" />}
智能分析
</button>
);
}
Create apps/web/components/analysis/AnalysisContextDrawer.tsx:
'use client';
import { FormEvent, useState } from 'react';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { X } from 'lucide-react';
import { requestAnalysis } from '@/lib/analysis-api';
import { AnalysisResultBlock } from './AnalysisResultBlock';
export function AnalysisContextDrawer({
open,
title,
context,
permissions,
onClose,
}: {
open: boolean;
title: string;
context: NonNullable<AnalysisRequest['context']>;
permissions: string[];
onClose: () => void;
}) {
const [question, setQuestion] = useState('');
const [response, setResponse] = useState<AnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
if (!open) return null;
async function ask(prompt: string) {
const value = prompt.trim();
if (!value) return;
setLoading(true);
try {
setResponse(await requestAnalysis({ question: value, context }, permissions));
setQuestion('');
} finally {
setLoading(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void ask(question);
}
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
<aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
<header className="flex h-14 items-center justify-between border-b border-white/70 px-5">
<div>
<p className="text-[13px] font-semibold text-[#0f172a]">{title}</p>
<p className="text-[11px] text-[#64748b]">只读取当前上下文和你已有权限的数据</p>
</div>
<button type="button" onClick={onClose} className="rounded-full p-2 text-[#64748b] hover:bg-white">
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<form onSubmit={submit} className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-sm">
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
rows={3}
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] outline-none placeholder:text-[#94a3b8]"
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
/>
<div className="flex justify-end">
<button disabled={loading || !question.trim()} className="rounded-full bg-[#0f172a] px-4 py-2 text-[13px] font-medium text-white disabled:bg-[#cbd5e1]">
{loading ? '分析中' : '开始分析'}
</button>
</div>
</form>
{response && <AnalysisResultBlock response={response} onAsk={ask} />}
</div>
</aside>
</div>
);
}
- Step 2: Add version detail entry
In apps/web/app/versions/[id]/page.tsx, import:
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
Add state near other modal states:
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const currentPermissions = currentRole?.permissions ?? [];
Place <AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} /> in the version header action area.
Render drawer near existing modals:
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`版本智能分析 · ${version.name}`}
context={{ surface: 'version_detail', versionId: version.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
- Step 3: Add product and project entries
In apps/web/app/products/[id]/page.tsx, import AnalysisEntryButton, AnalysisContextDrawer, useAuthStore, and useMemberStore. Add showAnalysisDrawer state plus:
const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
Place the button in the product page header and render:
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`智能分析 · ${currentProduct.name}`}
context={{ surface: 'product_detail', productId: currentProduct.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
In apps/web/app/projects/[id]/page.tsx, reuse the existing user and roles variables, add:
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
Place the button in the project page header and render:
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`项目智能分析 · ${project.name}`}
context={{ surface: 'project_detail', projectId: project.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
- Step 4: Run frontend type-check
Run: pnpm --filter web type-check
Expected: command exits 0.
- Step 5: Commit
git add apps/web/components/analysis/AnalysisEntryButton.tsx \
apps/web/components/analysis/AnalysisContextDrawer.tsx \
'apps/web/app/products/[id]/page.tsx' \
'apps/web/app/projects/[id]/page.tsx' \
'apps/web/app/versions/[id]/page.tsx'
git commit -m "feat(ai-analysis): 添加详情页智能分析入口"
Task 9: Documentation, Verification, and UI QA
Files:
- Modify:
docs/agent-spec.md - Modify:
docs/roadmap.md - Modify:
docs/workflow.md - Optional screenshots saved outside git unless the user asks to keep them.
Interfaces:
-
Consumes: all previous tasks.
-
Produces: final documented implementation boundary and verification evidence.
-
Step 1: Update docs after implementation
Update docs/agent-spec.md Business Analysis Agent section:
Business Analysis Agent 第一版已接入 `/api/v1/ai/analysis`、`/wenfan-xiaobao` 和产品/项目/版本详情页上下文入口。第一版支持 Template Strategy 和 Deterministic Rule Composition,AI Planning 只生成受控计划建议并经过 Analysis Plan Processor 校验。图表契约为 Unified ChartSpec,前端通过 ECharts Renderer 渲染。
Update docs/roadmap.md V3.4:
### V3.4 — Business Analysis Agent(已完成第一版)
Add completion bullets for:
- Shared analysis contract.
- Backend analysis endpoint and Metric Engine.
- ECharts Renderer.
- AI Assistant analysis conversation.
- Product/project/version context entrypoints.
Update docs/workflow.md with the implemented endpoint:
业务分析请求走 `POST /api/v1/ai/analysis`。前端必须传入 `surface` 和可用上下文 ID;后端基于当前用户和上下文再次收窄范围,不能只信任前端。
- Step 2: Run full verification commands
Run:
pnpm --filter @ftb/shared type-check
pnpm --filter server test -- analysis
pnpm --filter server type-check
pnpm --filter web test -- analysis
pnpm --filter web type-check
git diff --check
Expected:
-
Shared type-check exits
0. -
Server focused tests pass.
-
Server type-check exits
0. -
Web focused tests pass.
-
Web type-check exits
0. -
git diff --checkprints no whitespace errors. -
Step 3: Run local UI smoke if dev server is available
If a Next.js dev server is already running, run:
curl -I http://localhost:3000/wenfan-xiaobao
curl -I http://localhost:3000/products
curl -I http://localhost:3000/projects
curl -I http://localhost:3000/versions
Expected: each returns HTTP/1.1 200 or HTTP/1.1 307 for auth redirect depending on current local auth state.
If no dev server is running, start it:
pnpm dev --filter=web
Keep the session running only long enough to verify pages and screenshots, then stop it before final response.
- Step 4: Visual QA with browser testing
Use the browser testing tool or Playwright to inspect:
/wenfan-xiaobao- One product detail page
- One project detail page
- One version detail page
Checks:
-
Insight Card appears before chart and report.
-
Large number is the primary visual in numeric insight.
-
Horizontal bars are single-color and rounded.
-
Line chart has smooth line, subtle area fill, and minimal axes.
-
Long labels do not overflow on desktop and mobile widths.
-
No-data and no-permission responses do not show empty chart frames.
-
Step 5: Commit final docs and polish
git add docs/agent-spec.md docs/roadmap.md docs/workflow.md
git commit -m "docs(ai-analysis): 更新业务分析Agent落地状态"
- Step 6: Final branch status
Run:
git status --short
git log --oneline -8
Expected: working tree is clean except for intentionally uncommitted local files, and recent commits show the task commits in order.