Compare commits

...

11 Commits

Author SHA1 Message Date
1e6fb0c7aa feat(ai-analysis): 添加详情页智能分析入口
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
2026-07-09 09:53:34 +08:00
6d2999085e feat(ai-analysis): 在AI助手展示业务分析结果 2026-07-09 09:32:28 +08:00
c724446bbd fix(ai-analysis): 收紧分析接口权限来源 2026-07-08 22:09:11 +08:00
56c8f59d13 feat(ai-analysis): 接入业务分析接口 2026-07-08 22:09:11 +08:00
7cd18dabac feat(ai-analysis): 实现业务分析指标引擎 2026-07-08 21:32:40 +08:00
889a34cfa1 feat(ai-analysis): 添加分析权限范围解析 2026-07-08 21:10:25 +08:00
89ee44422b feat(ai-analysis): 添加图表渲染和分析API客户端 2026-07-08 21:07:42 +08:00
04db0000b5 feat(ai-analysis): 添加语义层和分析计划处理器 2026-07-08 21:07:33 +08:00
b6b7ebf44f feat(ai-analysis): 添加业务分析共享契约 2026-07-08 20:56:20 +08:00
74c55df59b docs(ai-analysis): 编写业务分析Agent实施计划 2026-07-08 20:45:32 +08:00
5933b84bf6 docs(ai): 设计业务分析Agent
新增 Business Analysis Agent 设计稿,明确 Semantic Layer、Metric Catalog、Analysis Strategy、Analysis Plan Processor、Metric Engine、统一 ChartSpec、Insight/Report/Evidence/Follow-up 和 Apple Vision 图表规范。

同步记录关键架构决策,约束 AI 只提出分析计划建议,系统校验规范化后执行,且不得绕过权限或直接生成 SQL。

Co-Authored-By: GPT-5 Codex <codex@openai.com>
2026-07-08 20:21:09 +08:00
51 changed files with 6759 additions and 41 deletions

View File

@@ -114,6 +114,22 @@ describe('PermissionService', () => {
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException); await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException); await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('resolves configured role permissions for the current user', async () => {
appDataFindUnique.mockResolvedValue({
value: {
roles: [
{ id: 'role-dev', permissions: ['project:view'] },
],
},
});
await expect(service.resolveUserPermissions(currentUser({ roleId: 'role-dev' }))).resolves.toEqual(['project:view']);
});
it('returns no permissions for anonymous users', async () => {
await expect(service.resolveUserPermissions(null)).resolves.toEqual([]);
});
}); });
function currentUser(overrides: Partial<CurrentUser>): CurrentUser { function currentUser(overrides: Partial<CurrentUser>): CurrentUser {

View File

@@ -117,6 +117,11 @@ const VERSION_WORK_MUTATION_PERMISSIONS = new Set([
export class PermissionService { export class PermissionService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async resolveUserPermissions(user: CurrentUser | null | undefined): Promise<string[]> {
if (!user) return [];
return this.resolveRolePermissions(user.roleId);
}
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> { async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
if (!user) return false; if (!user) return false;
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true; if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;

View File

@@ -0,0 +1,32 @@
import { AiController } from './ai.controller';
describe('AiController', () => {
it('uses server-derived permissions for analysis instead of body permissions', async () => {
const aiService = {};
const businessAnalysisService = {
analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }),
};
const authContext = {
resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }),
};
const permissionService = {
resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']),
};
const controller = new (AiController as any)(
aiService,
businessAnalysisService,
authContext,
permissionService,
) as AiController;
const request = { headers: { 'x-ftb-user-id': 'm-1' } };
await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request);
expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request);
expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' });
expect(businessAnalysisService.analyze).toHaveBeenCalledWith(
{ question: '分析版本风险', permissions: ['*'] },
{ id: 'm-1', permissions: ['project:view'] },
);
});
});

View File

@@ -1,5 +1,9 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post, Req } from '@nestjs/common';
import { AuthContextService, type AuthenticatedRequest } from '../../common/auth/auth-context.service';
import { PermissionService } from '../../common/auth/permission.service';
import { AiService } from './ai.service'; 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 { DecomposeDto } from './dto/decompose.dto';
import { RiskInterpretDto } from './dto/risk-interpret.dto'; import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type { import type {
@@ -7,11 +11,17 @@ import type {
AgentDecomposeError, AgentDecomposeError,
AgentRiskInterpretResponse, AgentRiskInterpretResponse,
AgentRiskInterpretError, AgentRiskInterpretError,
AnalysisResponse,
} from '@ftb/shared'; } from '@ftb/shared';
@Controller('ai') @Controller('ai')
export class AiController { export class AiController {
constructor(private readonly aiService: AiService) {} constructor(
private readonly aiService: AiService,
private readonly businessAnalysisService: BusinessAnalysisService,
private readonly authContext: AuthContextService,
private readonly permissionService: PermissionService,
) {}
@Post('decompose') @Post('decompose')
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> { async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
@@ -22,4 +32,17 @@ export class AiController {
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> { async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto); return this.aiService.interpretRisk(dto);
} }
@Post('analysis')
async analyze(
@Body() dto: AnalysisDto,
@Req() request: AuthenticatedRequest,
): Promise<AnalysisResponse> {
const user = await this.authContext.resolveCurrentUser(request);
const permissions = await this.permissionService.resolveUserPermissions(user);
return this.businessAnalysisService.analyze(dto, {
id: user?.id ?? '',
permissions,
});
}
} }

View File

@@ -3,11 +3,24 @@ import { AiController } from './ai.controller';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
import { AiGatewayService } from './ai-gateway.service'; import { AiGatewayService } from './ai-gateway.service';
import { ConfigModule } from '../config/config.module'; import { ConfigModule } from '../config/config.module';
import { AuthModule } from '../../common/auth/auth.module';
import { CommonDomainModule } from '../../common/common-domain.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({ @Module({
imports: [ConfigModule], imports: [ConfigModule, AuthModule, CommonDomainModule],
controllers: [AiController], controllers: [AiController],
providers: [AiService, AiGatewayService], providers: [
AiService,
AiGatewayService,
BusinessAnalysisService,
PermissionScopeResolver,
MetricEngine,
AnalysisReportBuilder,
],
exports: [AiService], exports: [AiService],
}) })
export class AiModule {} export class AiModule {}

View File

@@ -0,0 +1,53 @@
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',
);
});
});

View File

@@ -0,0 +1,89 @@
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 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;
return {
metricRef: { metricId: metric.metricId, version: metric.version },
analysisType: input.analysisType,
dimensions: input.dimensions.length > 0
? input.dimensions
: metric.defaultDimension
? [metric.defaultDimension]
: [],
filters: input.filters ?? {},
scope: input.scope,
...(timeRange ? { timeRange } : {}),
limit: normalizeLimit(input.limit, input.analysisType),
...(input.sort ? { sort: input.sort } : {}),
};
}
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.setUTCHours(23, 59, 59, 999);
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - 29);
start.setUTCHours(0, 0, 0, 0);
return {
start: start.toISOString(),
end: end.toISOString(),
policy: 'last_30_days',
};
}

View File

@@ -0,0 +1,29 @@
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',
});
});
});

View File

@@ -0,0 +1,66 @@
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 };
}

View File

@@ -0,0 +1,53 @@
import type { AnalysisPlan, AnalysisRequest, DimensionId } from '@ftb/shared';
import { parseSemanticIntent } from './analysis-semantic-layer';
import type { SemanticIntent } from './analysis-semantic-layer';
import { normalizeAnalysisPlan } from './analysis-plan-processor';
import { getMetricDefinition } from './metric-catalog';
export function createAnalysisPlanFromQuestion(
request: AnalysisRequest,
scope: AnalysisPlan['scope'],
now = new Date(),
): { semantic: SemanticIntent; plan: AnalysisPlan } {
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'],
): DimensionId[] {
const metric = getMetricDefinition(metricId);
const preferred = inferPreferredDimension(question, metricId);
if (preferred && metric?.supportedDimensions.includes(preferred)) return [preferred];
if (metric?.defaultDimension) return [metric.defaultDimension];
return [];
}
function inferPreferredDimension(
question: string,
metricId: AnalysisPlan['metricRef']['metricId'],
): DimensionId | null {
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 null;
}

View File

@@ -0,0 +1,88 @@
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: { type: string }) => 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' });
});
});

View File

@@ -0,0 +1,104 @@
import { BadRequestException, ForbiddenException, 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 { validateAnalysisPlan } from './analysis-plan-processor';
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: '请输入要分析的问题。' };
}
let scope;
try {
scope = await this.scopeResolver.resolveAnalysisScope({
actorId: actor.id,
permissions: actor.permissions,
context: request.context,
});
} catch (error) {
if (error instanceof ForbiddenException) {
return { ok: false, code: 'NO_PERMISSION', message: '当前用户没有该范围的分析权限。' };
}
throw error;
}
const { semantic, plan } = createAnalysisPlanFromQuestion({ ...request, question }, scope, now);
if (semantic.semanticConfidence === 'low') {
return {
ok: false,
code: 'AMBIGUOUS_INTENT',
message: '这个问题有多种理解,请选择一个分析方向。',
clarificationOptions: [
{ label: '成员负载', prompt: '分析成员待办排行' },
{ label: '版本风险', prompt: '分析版本风险排行' },
],
dataScope: scope,
};
}
try {
validateAnalysisPlan(plan);
} catch (error) {
if (error instanceof BadRequestException) {
return {
ok: false,
code: 'INVALID_PLAN',
message: error.message,
dataScope: scope,
};
}
throw error;
}
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, plan);
const followUps = buildFollowUps(metricResult, plan);
return { ok: true, plan, metricResult, insight, chart, report, followUps };
}
}

View File

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

View File

@@ -0,0 +1,17 @@
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 ? `为什么${String(top.label)}最高?` : '换一个维度继续分析';
return [
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
{
type: 'drilldown',
label: '查看明细',
target: plan.dimensions[0] ?? 'analysis',
filters: plan.filters,
},
{ type: 'export', label: '导出报告', format: 'pdf' },
];
}

View File

@@ -0,0 +1,23 @@
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 topValue = top?.value ?? result.totals?.value ?? 0;
const topLabel = 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}暂无可分析数据。`
: `${topLabel}${metric.name}中最突出。`,
primaryValue: { label: metric.name, value: topValue },
semanticConfidence,
dataConfidence,
};
}

View File

@@ -0,0 +1,21 @@
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');
});
});

View File

@@ -0,0 +1,210 @@
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;
}

View File

@@ -0,0 +1,93 @@
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' }]);
});
});

View File

@@ -0,0 +1,461 @@
import { Injectable } from '@nestjs/common';
import type { AnalysisPlan, DataScope, EvidenceItem, MetricResult } from '@ftb/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { buildScopedWhere } from './permission-scope-resolver';
type AnalysisDomain =
| 'version'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'requirement'
| 'taskWorklog'
| 'overtimeRecord';
type OpenWorkItem = {
id: string;
versionId?: string | null;
title: string;
ownerId?: string | null;
assigneeId?: string | null;
};
type VersionRow = { id: string; name: string; projectId?: string | null; productId?: string | null };
type RiskSummaryRow = { versionId: string; riskLevel: string; riskScore: number; updatedAt: Date };
type UserRow = { id: string; name: string; departmentId?: string | null };
type UserDepartmentRow = { id: string; departmentId?: string | null };
type StatusRow = { status: string };
type RequirementSourceRow = { sourceType: string | null; type: string | null };
type DateRow = { updatedAt: Date };
type WorkHoursRow = { userId: string | null; hours: number };
type BugSeverityRow = { severity: string };
type TestStatusRow = { status: string; updatedAt: Date };
type OvertimeReasonRow = { reason: string; hours: number };
@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 versions = await this.prisma.version.findMany({
where: domainWhere(plan.scope, 'version'),
select: { id: true, name: true, projectId: true, productId: true },
}) as VersionRow[];
const versionById = new Map(versions.map((version) => [version.id, version]));
const versionIds = versions.map((version) => version.id);
const rows: RiskSummaryRow[] = versionIds.length === 0
? []
: await this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versionIds } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
take: plan.limit ?? 10,
}) as RiskSummaryRow[];
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) => ({
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 [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' } },
}),
this.prisma.devTask.findMany({
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' } },
}),
this.prisma.testCase.findMany({
where: { ...domainWhere(plan.scope, 'testCase'), status: { notIn: ['passed', 'failed', 'blocked'] } },
}),
this.prisma.bug.findMany({
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
}),
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
const openItems: OpenWorkItem[] = [
...plans.map((planItem) => ({ ...planItem, assigneeId: planItem.ownerId })),
...devTasks,
...testCases,
...bugs,
];
const counts = countByMember(openItems);
const users = await this.prisma.user.findMany({
where: { id: { in: Array.from(counts.keys()) } },
select: { id: true, name: true, departmentId: true },
}) as UserRow[];
const userById = new Map(users.map((user) => [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: sumCounts(counts), 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 },
}) as UserDepartmentRow[];
const departmentByMember = new Map(users.map((user) => [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 [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' }, expectedEndAt: { lt: now } },
}),
this.prisma.devTask.findMany({
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' }, expectedEndAt: { lt: now } },
}),
this.prisma.testCase.findMany({
where: {
...domainWhere(plan.scope, 'testCase'),
status: { notIn: ['passed', 'failed', 'blocked'] },
plannedEndAt: { lt: now },
},
}),
this.prisma.bug.findMany({
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } },
}),
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
const byVersion = new Map<string, number>();
for (const item of [...plans, ...devTasks, ...testCases, ...bugs]) {
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: domainWhere(plan.scope, 'requirement'),
select: { status: true },
}) as StatusRow[];
return countRows(plan, now, rows.map((row) => row.status || 'unknown'), '状态', '数量', 'requirement');
}
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = {
...domainWhere(plan.scope, 'requirement'),
status: { in: ['released', 'closed'] },
...timeFilter(plan, 'updatedAt'),
};
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } }) as DateRow[];
return trendRows(plan, now, rows.map((row) => row.updatedAt), '完成需求', 'requirement');
}
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = { ...domainWhere(plan.scope, 'requirement'), ...timeFilter(plan, 'createdAt') };
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } }) as RequirementSourceRow[];
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
return countRows(plan, now, rows.map((row) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
}
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const [worklogs, overtime] = await Promise.all([
this.prisma.taskWorklog.findMany({
where: { ...domainWhere(plan.scope, 'taskWorklog'), ...timeFilter(plan, 'createdAt') },
select: { userId: true, hours: true },
}),
this.prisma.overtimeRecord.findMany({
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
select: { userId: true, hours: true },
}),
]) as [WorkHoursRow[], WorkHoursRow[]];
const hours = new Map<string, number>();
for (const row of [...worklogs, ...overtime]) {
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 },
}) as Array<Pick<UserRow, 'id' | 'name'>>;
const userById = new Map(users.map((user) => [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: { ...domainWhere(plan.scope, 'bug'), status: { notIn: ['closed', 'rejected'] } },
select: { severity: true },
}) as BugSeverityRow[];
return countRows(plan, now, rows.map((row) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
}
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.testCase.findMany({
where: {
...domainWhere(plan.scope, 'testCase'),
status: { in: ['passed', 'failed', 'blocked'] },
...timeFilter(plan, 'updatedAt'),
},
select: { status: true, updatedAt: true },
}) as TestStatusRow[];
const buckets = bucketDates(rows.map((row) => row.updatedAt));
const source = Array.from(buckets.keys()).map((label) => {
const sameDay = rows.filter((row) => dayKey(row.updatedAt) === label);
const passed = sameDay.filter((row) => 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 rows = await this.prisma.overtimeRecord.findMany({
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
select: { reason: true, hours: true },
}) as OvertimeReasonRow[];
const hours = new Map<string, number>();
for (const row of rows) {
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> {
return this.requirementCompletionTrend({
...plan,
metricRef: { metricId: 'requirement_completion_count', version: 1 },
dimensions: ['day'],
}, 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 domainWhere(scope: DataScope, domain: AnalysisDomain): Record<string, unknown> {
const scopedWhere = buildScopedWhere(scope);
const base = domain === 'version' && 'versionId' in scopedWhere
? { id: scopedWhere.versionId }
: scopedWhere;
if (scope.type !== 'self') return base;
if (domain === 'version') return { id: { in: [] } };
if (domain === 'versionPlan') return { ...base, ownerId: scope.userId };
if (domain === 'devTask' || domain === 'testCase' || domain === 'bug') return { ...base, assigneeId: scope.userId };
if (domain === 'requirement') return { ...base, creatorId: scope.userId };
if (domain === 'taskWorklog' || domain === 'overtimeRecord') return { ...base, userId: scope.userId };
return base;
}
function timeFilter(plan: AnalysisPlan, field: string): Record<string, unknown> {
if (!plan.timeRange) return {};
return { [field]: { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) } };
}
function countByMember(items: OpenWorkItem[]): Map<string, number> {
const counts = new Map<string, number>();
for (const item of items) {
const memberId = item.assigneeId ?? item.ownerId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
return counts;
}
function sumCounts(counts: Map<string, number>): number {
return Array.from(counts.values()).reduce((sum, count) => sum + count, 0);
}
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[]): Map<string, number> {
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): 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(),
};
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { buildScopedWhere, 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('narrows product context to project memberships when the actor lacks global product access', async () => {
const { prisma, rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockRejectedValue(new ForbiddenException('No product permission'));
prisma.projectMember.findMany.mockResolvedValue([
{ projectId: 'project-1' },
{ projectId: 'project-1' },
{ projectId: 'project-2' },
]);
await expect(resolver.resolveAnalysisScope({
actorId: 'm-dev',
permissions: [],
context: { surface: 'product_detail', productId: 'product-1' },
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
});
it('rejects missing actor id', async () => {
const { resolver } = makeResolver();
await expect(resolver.resolveAnalysisScope({
actorId: '',
permissions: [],
context: { surface: 'ai_assistant' },
})).rejects.toBeInstanceOf(ForbiddenException);
});
it('builds relation-table where filters for concrete scopes', () => {
expect(buildScopedWhere({ type: 'product', productId: 'product-1' })).toEqual({ productId: 'product-1' });
expect(buildScopedWhere({ type: 'project', projectId: 'project-1' })).toEqual({ projectId: 'project-1' });
expect(buildScopedWhere({ type: 'version', versionId: 'version-1' })).toEqual({ versionId: 'version-1' });
expect(buildScopedWhere({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] })).toEqual({
projectId: { in: ['project-1', 'project-2'] },
});
});
});

View File

@@ -0,0 +1,165 @@
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'];
}
type ScopedWhere = {
productId?: string;
projectId?: string | { in: string[] };
versionId?: string;
};
@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 permissions = input.permissions ?? [];
const context = input.context;
if (context?.surface === 'version_detail' && context.versionId) {
return this.resolveVersionScope(actorId, permissions, context.versionId);
}
if (context?.surface === 'project_detail' && context.projectId) {
await this.rbac.assertProjectRole({
actorId,
projectId: context.projectId,
allowedRoles: ['viewer'],
permissions,
});
return { type: 'project', projectId: context.projectId };
}
if (context?.surface === 'product_detail' && context.productId) {
return this.resolveProductScope(actorId, permissions, context.productId);
}
if (permissions.includes('*')) {
await this.rbac.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['management:view'],
});
return { type: 'system', reason: 'admin' };
}
if (permissions.includes('management:view')) {
await this.rbac.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['management:view'],
});
return this.resolveManagedProjectsScope(actorId);
}
const managedScope = await this.resolveManagedProjectsScope(actorId);
if (managedScope.projectIds.length > 0) return managedScope;
return { type: 'self', userId: actorId };
}
private async resolveVersionScope(
actorId: string,
permissions: string[],
versionId: string,
): Promise<DataScope> {
const version = await this.prisma.version.findFirst({
where: { id: versionId },
select: { id: true, projectId: true },
});
if (!version) throw new NotFoundException('Version not found');
if (!version.projectId) {
await this.rbac.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['version:view', 'management:view'],
});
return { type: 'version', versionId: version.id };
}
await this.rbac.assertProjectRole({
actorId,
projectId: version.projectId,
allowedRoles: ['viewer'],
permissions,
});
return { type: 'version', versionId: version.id };
}
private async resolveProductScope(
actorId: string,
permissions: string[],
productId: string,
): Promise<DataScope> {
if (permissions.includes('*')) {
await this.rbac.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['management:view'],
});
return { type: 'product', productId };
}
if (permissions.includes('management:view') || permissions.includes('product:view')) {
try {
await this.rbac.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['management:view', 'product:view'],
});
return { type: 'product', productId };
} catch {
// Fall through to project membership narrowing.
}
}
const rows = await this.prisma.projectMember.findMany({
where: {
userId: actorId,
project: { productId },
},
select: { projectId: true },
});
const projectIds = uniqueProjectIds(rows);
if (projectIds.length === 0) throw new ForbiddenException('No product analysis scope');
return { type: 'managed_projects', projectIds };
}
private async resolveManagedProjectsScope(actorId: string): Promise<Extract<DataScope, { type: 'managed_projects' }>> {
const rows = await this.prisma.projectMember.findMany({
where: {
userId: actorId,
role: { in: ['owner', 'admin'] },
},
select: { projectId: true },
});
return { type: 'managed_projects', projectIds: uniqueProjectIds(rows) };
}
}
export function buildScopedWhere(scope: DataScope): ScopedWhere {
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 {};
}
function uniqueProjectIds(rows: Array<{ projectId: string | null | undefined }>): string[] {
return Array.from(new Set(rows.map((row) => row.projectId).filter((projectId): projectId is string => Boolean(projectId))));
}

View File

@@ -0,0 +1,54 @@
import { AnalysisReportBuilder } from './report-builder';
import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
describe('AnalysisReportBuilder', () => {
it('describes normalized time ranges from the analysis plan', async () => {
const builder = new AnalysisReportBuilder();
const metric = {
metricId: 'member_effort_hours',
version: 1,
name: '成员投入工时',
description: '成员投入',
formula: 'sum(hours)',
owner: 'management',
supportedDimensions: ['member'],
supportedAnalysisTypes: ['ranking'],
defaultChart: 'horizontal_bar',
defaultTimePolicy: 'last_30_days',
status: 'active',
} as MetricDefinition;
const result = {
metricRef: { metricId: 'member_effort_hours', version: 1 },
analysisType: 'ranking',
columns: [
{ id: 'label', label: '成员', type: 'string' },
{ id: 'value', label: '小时', type: 'number' },
],
rows: [{ label: '张三', value: 8 }],
evidence: [],
dataScope: { type: 'self', userId: 'm-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
} as MetricResult;
const insight = {
summary: '张三投入最高。',
semanticConfidence: 'high',
dataConfidence: 'partial',
} as InsightCard;
const plan = {
metricRef: { metricId: 'member_effort_hours', version: 1 },
analysisType: 'ranking',
dimensions: ['member'],
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',
},
} as AnalysisPlan;
const report = await (builder.build as any)(result, insight, metric, plan);
expect(report.dataScope.timeDescription).toBe('最近 30 天');
});
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import type {
AnalysisPlan,
AnalysisReport,
DataScope,
InsightCard,
MetricDefinition,
MetricResult,
} from '@ftb/shared';
@Injectable()
export class AnalysisReportBuilder {
async build(
result: MetricResult,
insight: InsightCard,
metric: MetricDefinition,
plan?: AnalysisPlan,
): Promise<AnalysisReport> {
return {
summary: insight.summary,
keyFindings: result.rows
.slice(0, 4)
.map((row) => `${String(row.label ?? '对象')}${String(row.value ?? 0)}`),
evidence: result.evidence,
suggestions:
result.rows.length > 0
? ['优先查看排名靠前的对象,并进入明细确认原因。']
: ['调整时间范围或切换分析维度。'],
dataScope: {
timeDescription: describeTimeScope(plan, 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 describeTimeScope(plan: AnalysisPlan | undefined, result: MetricResult): string {
if (!plan?.timeRange) {
return resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态';
}
if (plan.timeRange.policy === 'current_state') return '当前状态';
if (plan.timeRange.policy === 'last_30_days') return '最近 30 天';
if (plan.timeRange.policy === 'lifecycle') return '对象生命周期';
if (plan.timeRange.policy === 'user_required') return '用户指定时间范围';
return `${formatDate(plan.timeRange.start)}${formatDate(plan.timeRange.end)}`;
}
function formatDate(value: string): string {
return value.slice(0, 10);
}
function describeScope(scope: DataScope): string {
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}`;
}

View File

@@ -0,0 +1,34 @@
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[];
}

View File

@@ -0,0 +1,103 @@
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;

View File

@@ -0,0 +1,22 @@
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;

View File

@@ -10,6 +10,10 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { RequirementTable } from '@/components/product/RequirementTable'; import { RequirementTable } from '@/components/product/RequirementTable';
import { RequirementForm } from '@/components/product/RequirementForm'; import { RequirementForm } from '@/components/product/RequirementForm';
import { ProductForm } from '@/components/product/ProductForm'; import { ProductForm } from '@/components/product/ProductForm';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
const TABS = [ const TABS = [
{ key: 'requirements' as const, label: '需求池' }, { key: 'requirements' as const, label: '需求池' },
@@ -29,10 +33,13 @@ export default function ProductDetailPage() {
deleteRequirement, deleteRequirement,
} = useRequirementStore(); } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore(); const { records, fetchRecords } = useOvertimeStore();
const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const [showReqForm, setShowReqForm] = useState(false); const [showReqForm, setShowReqForm] = useState(false);
const [editingReq, setEditingReq] = useState<any>(null); const [editingReq, setEditingReq] = useState<any>(null);
const [editingProduct, setEditingProduct] = useState(false); const [editingProduct, setEditingProduct] = useState(false);
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements'); const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null); const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null);
@@ -47,6 +54,10 @@ export default function ProductDetailPage() {
if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter); if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter);
return list; return list;
}, [requirements, productId, reqStatusFilter]); }, [requirements, productId, reqStatusFilter]);
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
const handleFilterChange = (status: RequirementStatus | null) => { const handleFilterChange = (status: RequirementStatus | null) => {
setReqStatusFilter(status); setReqStatusFilter(status);
@@ -93,7 +104,8 @@ export default function ProductDetailPage() {
{currentProduct.name} {currentProduct.name}
</h1> </h1>
</div> </div>
<div className="flex gap-1.5"> <div className="flex items-center gap-1.5">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
<button <button
onClick={() => setEditingProduct(true)} onClick={() => setEditingProduct(true)}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]" className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
@@ -215,6 +227,14 @@ export default function ProductDetailPage() {
)} )}
</div> </div>
</div> </div>
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`产品智能分析 · ${currentProduct.name}`}
context={{ surface: 'product_detail', productId: currentProduct.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div> </div>
); );
} }

View File

@@ -19,6 +19,8 @@ import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from
import { CapsuleStages } from '@/components/version/CapsuleStages'; import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips'; import { MemberChips } from '@/components/version/MemberChips';
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel'; import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan'; import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview'; import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
import { calcScopedVersionProgress } from '@/lib/version-progress'; import { calcScopedVersionProgress } from '@/lib/version-progress';
@@ -327,6 +329,7 @@ export default function ProjectDetailPage() {
const { testCases, fetchTestCases } = useTestCaseStore(); const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore(); const { bugs, fetchBugs } = useBugStore();
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
@@ -340,6 +343,10 @@ export default function ProjectDetailPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || ''; const currentUserName = user?.name || '';
const { roles } = useMemberStore(); const { roles } = useMemberStore();
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
const isSuperAdmin = useMemo(() => { const isSuperAdmin = useMemo(() => {
const r = roles.find((x) => x.id === user?.roleId); const r = roles.find((x) => x.id === user?.roleId);
return !!r && r.permissions.includes('*'); return !!r && r.permissions.includes('*');
@@ -435,9 +442,12 @@ export default function ProjectDetailPage() {
<span className="ml-2 text-[var(--ink-muted)]">/</span> <span className="ml-2 text-[var(--ink-muted)]">/</span>
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span> <span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span>
</div> </div>
<div className="flex items-center gap-2">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]"> <div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
<Package className="h-3 w-3" /><span>{project.productName}</span> <Package className="h-3 w-3" /><span>{project.productName}</span>
</div> </div>
</div>
</header> </header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]"> <div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
@@ -482,6 +492,13 @@ export default function ProjectDetailPage() {
</section> </section>
</div> </div>
</div> </div>
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`项目智能分析 · ${project.name}`}
context={{ surface: 'project_detail', projectId: project.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div> </div>
); );
} }

View File

@@ -17,6 +17,8 @@ import { PlanTab } from '@/components/version/PlanTab';
import { DevTaskTab } from '@/components/dev-task/DevTaskTab'; import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
import { TestCaseTab } from '@/components/test-case/TestCaseTab'; import { TestCaseTab } from '@/components/test-case/TestCaseTab';
import { BugTab } from '@/components/bug/BugTab'; import { BugTab } from '@/components/bug/BugTab';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
@@ -135,6 +137,7 @@ export default function VersionDetailPage() {
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
const currentPermissions = currentRole?.permissions ?? [];
const memberCandidates = useMemo( const memberCandidates = useMemo(
() => allMembers.map((member) => ({ () => allMembers.map((member) => ({
id: member.id, id: member.id,
@@ -157,6 +160,7 @@ export default function VersionDetailPage() {
const [showRecommendModal, setShowRecommendModal] = useState(false); const [showRecommendModal, setShowRecommendModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [showReleaseModal, setShowReleaseModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false);
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false);
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null); const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
@@ -432,7 +436,10 @@ export default function VersionDetailPage() {
<span className="ml-1.5 text-[var(--ink-muted)]">/</span> <span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span> <span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
</div> </div>
<div className="flex items-center gap-2">{renderActions()}</div> <div className="flex items-center gap-2">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
{renderActions()}
</div>
</header> </header>
{/* Tab bar */} {/* Tab bar */}
@@ -1184,6 +1191,13 @@ export default function VersionDetailPage() {
onClose={() => setShowMemberModal(false)} onClose={() => setShowMemberModal(false)}
/> />
)} )}
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`版本智能分析 · ${version.name}`}
context={{ surface: 'version_detail', versionId: version.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react'; import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
import type { AnalysisResponse } from '@ftb/shared';
import { import {
BotMessageSquare, BotMessageSquare,
Image as ImageIcon, Image as ImageIcon,
@@ -14,6 +15,8 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
import { requestAnalysis } from '@/lib/analysis-api';
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles'; import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
import { import {
createWenfanConversationRecord, createWenfanConversationRecord,
@@ -28,6 +31,8 @@ import {
shouldShowGenericHelpSuggestions, shouldShowGenericHelpSuggestions,
type HelpSearchResult, type HelpSearchResult,
} from '@/lib/wenfan-help-search'; } from '@/lib/wenfan-help-search';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
type ChatMessage = type ChatMessage =
| { | {
@@ -54,6 +59,18 @@ type ChatMessage =
type: 'fallback'; type: 'fallback';
content: string; content: string;
suggestions: string[]; suggestions: string[];
}
| {
id: string;
role: 'assistant';
type: 'analysis';
response: AnalysisResponse;
}
| {
id: string;
role: 'assistant';
type: 'analysis_error';
content: string;
}; };
const INITIAL_MESSAGES: ChatMessage[] = [ const INITIAL_MESSAGES: ChatMessage[] = [
@@ -62,7 +79,7 @@ const INITIAL_MESSAGES: ChatMessage[] = [
role: 'assistant', role: 'assistant',
type: 'intro', type: 'intro',
content: content:
'第一阶段只从内置帮助中心回答系统怎么用,不调用 AI也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。', '你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
}, },
]; ];
@@ -75,8 +92,14 @@ export default function WenfanXiaobaoPage() {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [conversations, setConversations] = useState<WenfanConversation[]>([]); const [conversations, setConversations] = useState<WenfanConversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null); const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const user = useAuthStore((state) => state.user);
const roles = useMemberStore((state) => state.roles);
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]); const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
const userQuestionCount = useMemo( const userQuestionCount = useMemo(
() => messages.filter((message) => message.role === 'user').length, () => messages.filter((message) => message.role === 'user').length,
[messages], [messages],
@@ -111,20 +134,47 @@ export default function WenfanXiaobaoPage() {
} }
} }
function askQuestion(rawQuestion: string) { async function askQuestion(rawQuestion: string) {
const question = rawQuestion.trim(); const question = rawQuestion.trim();
if (!question) return; if (!question) return;
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
const nextUserQuestionCount = userQuestionCount + 1;
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: `user-${Date.now()}`, id: `user-${Date.now()}`,
role: 'user', role: 'user',
type: 'text', type: 'text',
content: question, 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 analysisErrorMessage: ChatMessage = {
id: `analysis-error-${Date.now()}`,
role: 'assistant',
type: 'analysis_error',
content: '业务分析暂不可用,我先用内置帮助继续回答。',
};
const assistantMessage: ChatMessage = const assistantMessage: ChatMessage =
results.length > 0 results.length > 0
? { ? {
@@ -140,23 +190,22 @@ export default function WenfanXiaobaoPage() {
content: getFallbackHelpMessage(showFallbackSuggestions), content: getFallbackHelpMessage(showFallbackSuggestions),
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [], suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
}; };
const nextMessages = [...optimisticMessages, analysisErrorMessage, assistantMessage];
const nextMessages = [...messages, userMessage, assistantMessage];
setMessages(nextMessages); setMessages(nextMessages);
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages)); setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
setInput(''); }
} }
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
askQuestion(input); void askQuestion(input);
} }
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) { function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
askQuestion(input); void askQuestion(input);
} }
} }
@@ -227,7 +276,7 @@ export default function WenfanXiaobaoPage() {
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI </h1> <h1 className="truncate text-[15px] font-semibold text-[#171717]">AI </h1>
</div> </div>
</div> </div>
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]"></span> <span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]"> · </span>
</header> </header>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
@@ -247,7 +296,7 @@ export default function WenfanXiaobaoPage() {
<button <button
key={question} key={question}
type="button" type="button"
onClick={() => askQuestion(question)} onClick={() => void askQuestion(question)}
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]" className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
> >
{question} {question}
@@ -330,6 +379,12 @@ function MessageRow({
</div> </div>
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1 pt-0.5">
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>} {message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
{message.type === 'analysis_error' && (
<p className="rounded-[22px] border border-[#e2e8f0] bg-white/75 px-4 py-3 text-[13px] leading-6 text-[#64748b] shadow-sm backdrop-blur">
{message.content}
</p>
)}
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />} {message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
{message.type === 'fallback' && ( {message.type === 'fallback' && (
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -0,0 +1,19 @@
'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>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { type FormEvent, useState } from 'react';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { Loader2, Send, 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 || loading) return;
setLoading(true);
try {
const result = await requestAnalysis({ question: value, context }, permissions);
setResponse(result);
setQuestion('');
} catch {
setResponse({
ok: false,
code: 'AI_UNAVAILABLE',
message: '业务分析暂不可用,请稍后再试。',
});
} 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 shrink-0 items-center justify-between border-b border-white/70 px-5">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-[#0f172a]">{title}</p>
<p className="mt-0.5 text-[11px] text-[#64748b]"></p>
</div>
<button
type="button"
onClick={onClose}
className="ml-3 rounded-full p-2 text-[#64748b] transition-colors hover:bg-white hover:text-[#0f172a]"
title="关闭"
>
<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-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl"
>
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
rows={3}
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] leading-6 text-[#0f172a] outline-none placeholder:text-[#94a3b8]"
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
/>
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-[11px] text-[#64748b]"></span>
<button
type="submit"
disabled={loading || !question.trim()}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-[#0f172a] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#1e293b] disabled:cursor-not-allowed disabled:bg-[#cbd5e1]"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
{loading ? '分析中' : '开始分析'}
</button>
</div>
</form>
{response ? (
<AnalysisResultBlock response={response} onAsk={(prompt) => void ask(prompt)} />
) : (
<div className="rounded-[28px] border border-dashed border-[#dbe3ef] bg-white/55 p-6 text-[13px] leading-7 text-[#64748b]">
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { BarChart3, Sparkles } from 'lucide-react';
export function AnalysisEntryButton({
onClick,
compact = false,
}: {
onClick: () => void;
compact?: boolean;
}) {
const Icon = compact ? BarChart3 : Sparkles;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur transition-colors hover:bg-white"
title="智能分析"
>
<Icon className="h-3.5 w-3.5" />
</button>
);
}

View File

@@ -0,0 +1,37 @@
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>
);
}

View File

@@ -0,0 +1,44 @@
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 backdrop-blur-xl">
<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 border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] hover:bg-white"
>
{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>
);
}

View File

@@ -0,0 +1,25 @@
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>
);
}

View File

@@ -0,0 +1,23 @@
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={() => {
if (item.type === 'question') onAsk(item.prompt);
}}
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm transition-colors hover:bg-white disabled:cursor-default disabled:opacity-60"
disabled={item.type !== 'question'}
>
{item.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
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' ? '部分数据' : '数据不足';
}

View File

@@ -0,0 +1,26 @@
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;
}
});

View File

@@ -0,0 +1,6 @@
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 });
}

View File

@@ -0,0 +1,45 @@
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);
});

View File

@@ -0,0 +1,124 @@
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) => formatCategory(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) => formatNumeric(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) => formatCategory(row[labelField])),
},
series: [{
type: 'bar',
data: rows.map((row) => formatNumeric(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: formatCategory(row[labelField]), value: formatNumeric(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' as const : undefined,
name: item.label,
value: item.value,
})),
};
}
function formatCategory(value: string | number | null): string {
return value == null ? '' : String(value);
}
function formatNumeric(value: string | number | null): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
function readSource(path: string): string {
assert.equal(existsSync(path), true, `${path} should exist`);
return readFileSync(path, 'utf8');
}
test('analysis context drawer is read-only and reuses the shared analysis result block', () => {
const entryButton = readSource('components/analysis/AnalysisEntryButton.tsx');
const drawer = readSource('components/analysis/AnalysisContextDrawer.tsx');
assert.match(entryButton, /export function AnalysisEntryButton/);
assert.match(entryButton, /智能分析/);
assert.match(drawer, /requestAnalysis/);
assert.match(drawer, /AnalysisResultBlock/);
assert.match(drawer, /只读取当前上下文和已有权限数据/);
assert.match(drawer, /permissions: string\[\]/);
assert.doesNotMatch(drawer, /create(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /update(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /delete(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /use(Product|Requirement|VersionPlan|DevTask|TestCase|Bug)Store/);
});
test('product, project, and version detail pages wire analysis drawer with scoped contexts', () => {
const productPage = readSource('app/products/[id]/page.tsx');
const projectPage = readSource('app/projects/[id]/page.tsx');
const versionPage = readSource('app/versions/[id]/page.tsx');
for (const source of [productPage, projectPage, versionPage]) {
assert.match(source, /AnalysisEntryButton/);
assert.match(source, /AnalysisContextDrawer/);
assert.match(source, /showAnalysisDrawer/);
assert.match(source, /currentPermissions/);
}
assert.match(productPage, /surface: 'product_detail'/);
assert.match(productPage, /productId: currentProduct\.id/);
assert.match(projectPage, /surface: 'project_detail'/);
assert.match(projectPage, /projectId: project\.id/);
assert.match(versionPage, /surface: 'version_detail'/);
assert.match(versionPage, /versionId: version\.id/);
});

View File

@@ -54,6 +54,23 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/); assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
}); });
test('wenfan xiaobao page requests business analysis before preserving help fallback', () => {
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');
assert.match(page, /requestAnalysis/);
assert.match(page, /AnalysisResultBlock/);
assert.match(page, /type: 'analysis'/);
assert.match(page, /type: 'analysis_error'/);
assert.match(page, /currentPermissions/);
assert.match(page, /surface: 'ai_assistant'/);
assert.match(page, /业务分析只读取你已有权限的数据/);
assert.match(page, /业务分析暂不可用/);
assert.match(page, /searchHelpArticles\(question, WENFAN_HELP_ARTICLES\)/);
assert.match(page, /getFallbackHelpMessage\(showFallbackSuggestions\)/);
assert.match(page, /void askQuestion\(input\)/);
assert.match(page, /<AnalysisResultBlock response=\{message\.response\} onAsk=\{onAsk\} \/>/);
});
test('wenfan xiaobao page does not ship seeded conversation history', () => { test('wenfan xiaobao page does not ship seeded conversation history', () => {
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8'); const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');

View File

@@ -15,6 +15,8 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@ftb/shared": "workspace:*", "@ftb/shared": "workspace:*",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^14.2.0", "next": "^14.2.0",
"pinyin-pro": "^3.28.1", "pinyin-pro": "^3.28.1",

View File

@@ -694,3 +694,25 @@
- 治理字典使用软删除或使用中禁止硬删,变更必须写审计。 - 治理字典使用软删除或使用中禁止硬删,变更必须写审计。
**理由**:适配器把协作治理模块的权限和审计接入点收束在一层,既能复用 V2.5 的服务端控制面,也给后续 JWT/NextAuth 和企业级角色体系留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。 **理由**:适配器把协作治理模块的权限和审计接入点收束在一层,既能复用 V2.5 的服务端控制面,也给后续 JWT/NextAuth 和企业级角色体系留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。
## 53. Business Analysis Agent 采用语义层、指标目录和受控分析计划
**问题**:下一阶段需要让用户用自然语言围绕产品、项目、版本、需求、部门和用户多维度提问,并自动生成图表与分析报告。如果只做“问题 -> 固定模板 -> 查询”,后续会被模板数量卡住;如果让 AI 直接决定查询或生成 ECharts option则会带来权限绕过、口径不一致、不可复现和难以维护的问题。
**决策**
- 新增独立 Business Analysis Agent只读业务数据不修改任何业务实体、不创建草案、不触发状态流转。
- 自然语言先进入 Semantic Layer把“忙 / 压力 / 风险 / 延期 / 效率 / 质量 / 需求完成”等业务说法映射到受控 `metricId``dimensionId``analysisType``timeIntent``scopeIntent`
- Semantic Layer 输出内部 `semanticConfidence`;前端只展示高/中/低置信,不展示伪精确百分比。数据充分性另用 `dataConfidence` 表达。
- 建立 Metric Catalog记录 metric `version`、公式、owner、支持维度、支持分析类型、默认图表、默认维度和默认时间口径。只要公式或计算口径变更就提升 metric version纯展示变化不升版本。
- 分析策略分三层Template Strategy 优先命中高频模板Rule Composition Strategy 是确定性系统规则组合AI Planning Strategy 只在前两者无法覆盖时生成 `AnalysisPlan` 建议。
- AI 生成的 `AnalysisPlan` 必须只使用 Semantic Layer / Metric Catalog 暴露的指标、维度、筛选和聚合能力,并经过 Analysis Plan Processor 校验与规范化后才能执行。
- Analysis Plan Processor 不只校验,也负责 Normalize将模板、规则组合和 AI proposal 统一成标准 `AnalysisPlan`Metric Engine 只消费统一格式。
- Metric Engine 输出统一 `MetricResult`。ChartSpec Builder、Insight Engine、Report Builder 和 Follow-up Builder 并行消费同一份 MetricResult避免图表和报告互相耦合。
- ChartSpec 是平台统一契约,不是 ECharts option。前端第一版用 ECharts Renderer 渲染,未来可替换为其他图表引擎。
- Evidence 不是纯 chips而是可点击数据证据包含 label、value、sourceDomain 和 drilldown filters。
- Report 固定为 Summary / Key Findings / Evidence / Suggestions / Data Scope避免不同分析回答格式漂移。
- Follow-up 分为 question、drilldown、export。第一版只允许只读追问、明细跳转和导出不允许创建会议、分配负责人等写操作。
- 没有数据时走 No Data Strategy说明请求、范围、缺少的数据和可替代分析不让 AI 编造解释。
- 权限红线Analysis Agent 只能查询当前用户已有权限的数据,自然语言不能扩大范围;无权限时拒绝或返回授权范围内的空结果。
**理由**Semantic Layer 和 Metric Catalog 能把自然语言、业务口径和数据库字段解耦metric version 和 result snapshot 能支撑历史分析复现Analysis Plan Processor 保证 AI proposal 不直接变成系统执行;统一 ChartSpec 和 MetricResult 让 ECharts 只是当前 renderer而不是长期数据契约。这样第一版可以靠固定模板稳定交付后续又能通过确定性组合和受控 AI Planning 扩展能力。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
# Business Analysis Agent Design
## Goal
Build a read-only Business Analysis Agent that lets users ask FTB business data questions in natural language and receive an Insight Card, a chart, an analysis report, explainable evidence, and safe follow-up options.
The first product surface is the AI Assistant business-data conversation. The same analysis engine will also be embedded in product, project, and version detail pages with the current page context pre-filled.
## Approved Direction
- Use a dedicated Business Analysis Agent instead of extending Prototype Decompose Agent or Risk Watch Agent.
- Use ECharts as the first chart renderer, but do not expose raw ECharts options as the platform contract.
- Define a platform Unified ChartSpec and render it through a frontend Chart Renderer.
- Use Apple Vision style visual rules: large whitespace, large radius, light shadow, translucent material, clear hierarchy, natural motion, and content-first charts.
- Default to the last 30 days only for time-window analysis when the user did not specify a time range. Current status, risk, and lifecycle questions must use their natural business scope instead of forcing last 30 days.
- Every answer must include a structured report, not only a chart.
- The Agent must not mutate business data, create drafts, change workflow state, or bypass permissions.
## Non-Goals
- No AI-generated SQL.
- No direct AI database access.
- No arbitrary dashboard builder in the first implementation.
- No automatic business actions such as creating meetings, assigning owners, or changing due dates.
- No colorful BI big-screen style, 3D effects, dense dashboards, or raw table dumps as the primary answer.
- No new AppData key as a source of truth.
## Core Architecture
```text
User question + optional page context
Analysis Planner
Semantic Layer
Permission Scope Resolver
Analysis Strategy
1. Template Strategy
2. Rule Composition Strategy (Deterministic)
3. AI Planning Strategy
Analysis Plan Processor
- Validate: permission, metric, dimension, aggregation, time range, data scope
- Normalize: produce one standard AnalysisPlan shape
Metric Engine
Metric Result
├─ ChartSpec Builder
├─ Insight Engine
├─ Report Builder
└─ Follow-up Builder
UI Composition
Insight Card + Chart + Report + Evidence + Follow-ups
```
### Responsibility Boundaries
- **Analysis Planner** reads the question and current page context, then produces an initial intent.
- **Semantic Layer** maps business words such as "busy", "pressure", "risk", "delay", "efficiency", and "quality" to system-owned metrics and dimensions.
- **Permission Scope Resolver** converts the current user and page context into a queryable scope. It must never widen the user's existing data access.
- **Analysis Strategy** chooses how to build the analysis plan.
- **Analysis Plan Processor** validates and normalizes all plans, including AI-generated ones.
- **Metric Engine** executes deterministic relation-table queries and aggregations.
- **ChartSpec Builder**, **Insight Engine**, **Report Builder**, and **Follow-up Builder** consume the same Metric Result in parallel.
- **UI Composition** assembles the final response for chat and context-page surfaces.
## Analysis Strategy
### 1. Template Strategy
Use fixed high-frequency templates for stable MVP analyses such as requirement completion, version risk, member workload, department workload, and Bug distribution.
Template Strategy should be preferred whenever the user question cleanly matches a known template.
### 2. Rule Composition Strategy (Deterministic)
When no complete template matches, deterministic system rules may compose known semantic concepts, metrics, dimensions, and time policies.
Example:
```text
"最近两个月延期原因变化"
→ trend + delay_reason + month + project/version scope
```
This composition is not done by AI. It is a rule engine over the Semantic Layer and Metric Catalog.
### 3. AI Planning Strategy
When neither template nor deterministic rule composition covers the question, AI may generate an Analysis Plan proposal.
AI Planning constraints:
- AI only proposes an Analysis Plan.
- The plan may only use metrics, dimensions, filters, aggregations, and time policies exposed by the Semantic Layer and Metric Catalog.
- The plan must pass Analysis Plan Processor validation and normalization before execution.
- If the plan references an unsupported metric, dimension, aggregation, filter, or scope, the system rejects it and returns a clarification or unsupported-analysis response.
Enterprise principle: AI does not directly decide what the system executes. AI proposes; the system validates, normalizes, and executes.
## Semantic Layer
The Semantic Layer is the business vocabulary boundary between natural language and system metrics.
### First Semantic Concepts
| User wording | Semantic concept | Default interpretation |
|---|---|---|
| 忙 / 负载高 | `workload` | Open item count plus estimated or actual effort, with overtime as supporting evidence |
| 压力大 | `work_pressure` | Usually workload plus overdue and overtime; lower semantic confidence than "忙" |
| 风险高 | `release_risk` | Xiaobao summary, blockers, bugs, test completion, remaining time |
| 延期 | `delay` | Past planned end or expected release, or forecast release later than target |
| 效率 | `delivery_efficiency` | Completion volume per effort; fall back to completion trend if effort data is insufficient |
| 质量差 | `quality_risk` | Open bugs, critical bugs, failed cases, blocked cases, repeated test rounds |
| 需求完成 | `requirement_completion` | Requirement status plus version delivery progress |
### Semantic Confidence
Semantic parsing must produce an internal `semanticConfidence`.
- High confidence: direct wording maps clearly to one concept, such as "延期率" → `delay_rate`.
- Medium confidence: wording is common but may carry multiple business meanings, such as "压力最大" → `work_pressure`.
- Low confidence: wording is vague or could map to unrelated metrics.
UI display should use levels, not pseudo-precise numbers:
- `high`: proceed directly.
- `medium`: proceed but show the assumed interpretation in Data Scope.
- `low`: ask a clarification or show selectable interpretations.
Do not display "AI confidence 96%" as a primary user-facing claim. Internal scores may be kept for ranking candidate interpretations.
## Metric Catalog
Metric Catalog is the stable source of truth for what the Business Analysis Agent can measure.
```ts
type MetricId = string;
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';
type AnalysisType =
| 'ranking'
| 'trend'
| 'comparison'
| 'distribution'
| 'composition'
| 'correlation'
| 'breakdown'
| 'summary';
type TimePolicy =
| 'current_state'
| 'last_30_days'
| 'lifecycle'
| 'user_required'
| 'explicit_range';
type ChartKind =
| 'number_card'
| 'line_area'
| 'horizontal_bar'
| 'stacked_horizontal_bar'
| 'donut'
| 'table_preview';
type 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';
};
```
Metric versioning is required. If the formula or business calculation changes, increment `version`. Do not increment the metric version for pure chart styling, copy, or layout changes.
Metric Result and analysis reports must record:
```ts
type MetricRef = {
metricId: string;
version: number;
};
```
Historical reproducibility needs both:
- `metricRef`: which metric algorithm was used.
- `analysisSnapshot` or `resultSnapshot`: the aggregated result at the time, because live business rows may change later.
## Analysis Plan Processor
The Analysis Plan Processor replaces a narrow "validator" with a stronger validate-and-normalize boundary.
Normalized plan shape:
```ts
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' };
type 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' }>;
};
```
### Validate
It must check:
- Current user is allowed to access the requested scope.
- Metric exists and is active.
- Metric version exists or can resolve to current active version.
- Dimensions are supported by the metric.
- Analysis type is supported by the metric.
- Aggregation is legal for the metric and dimension.
- Time range is valid and within supported business bounds.
- Filters are known, typed, and compatible with the scope.
- Requested Top N is within system limits.
### Normalize
It converts all strategy outputs into the same `AnalysisPlan` shape.
Example:
```ts
// AI or user wording
{
time: '最近半年',
metric: '延期率'
}
// normalized
{
metricRef: { metricId: 'delay_rate', version: 1 },
analysisType: 'trend',
dimensions: ['month'],
timeRange: {
start: '2026-01-01',
end: '2026-06-30'
},
filters: {},
scope: { type: 'user_accessible' },
limit: 12
}
```
The Metric Engine only accepts normalized `AnalysisPlan`.
## Time Range Rules
1. If the user specifies a time range, use that exact range.
2. If the question asks current state or risk, use current state data instead of last 30 days.
3. If the question asks trend, throughput, efficiency, effort, or change and does not specify time, use last 30 days.
4. If the question asks comparison and does not specify a baseline, use last 30 days versus the previous 30 days.
5. If the question asks lifecycle totals, use the object lifecycle.
6. Every response must show the applied time scope, data cutoff time, permission scope, and whether the analysis is current-state, time-window, comparison, or lifecycle.
## Permissions
The Business Analysis Agent can only query data the current user can already access.
First implementation scope:
- AI Assistant global analysis is available to logged-in users.
- Normal users see only data related to themselves or contexts they can access.
- Users with `management:view`, system admin permissions, or project Owner/Admin governance can analyze their managed scope.
- Product, project, and version detail analysis follows the page's existing access rules and must not widen scope.
Natural language cannot bypass permission boundaries. If a user asks for a forbidden scope, return a no-permission response or an empty authorized result with clear Data Scope.
## MVP Analysis Templates
The MVP starts with fixed templates while preserving the three-layer Analysis Strategy for future flexibility.
| Group | Template | Default chart | Time policy | Default limit |
|---|---|---|---|---|
| Risk and progress | Version risk ranking | Horizontal bar + numeric card | Current state | Top 10 |
| Risk and progress | Project or version completion trend | Smooth line area | Last 30 days if unspecified | 30 points |
| Risk and progress | Overdue item distribution | Horizontal bar | Current state | Top 10 |
| Product and requirements | Product requirement status distribution | Donut + numeric card | Current state or explicit range | All statuses |
| Product and requirements | Requirement completion trend | Smooth line area | Last 30 days if unspecified | 30 points |
| Product and requirements | Requirement source/type composition | Donut | Last 30 days if unspecified | Top 8 + other |
| People and departments | Department workload ranking | Horizontal bar | Current state | Top 10 |
| People and departments | Member pending-work ranking | Horizontal bar | Current state | Top 10 |
| People and departments | Member effort ranking | Horizontal bar + numeric card | Last 30 days if unspecified | Top 10 |
| Quality | Bug severity distribution | Stacked horizontal bar | Current state | Top 10 |
| Quality | Test pass-rate trend | Smooth line area | Last 30 days if unspecified | 30 points |
| Effort | Overtime reason composition and ranking | Donut + horizontal bar | Last 30 days if unspecified | Top 8 |
Top N must be explicit in every ranking plan. Default is Top 10. The first implementation should cap user-requested limits to a safe maximum, such as Top 20.
## Metric Result
Metric Result is the reusable output of the Metric Engine. Chart, report, insight, export, and future dashboard cards consume this result in parallel.
```ts
type 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;
};
```
## Evidence
Evidence is clickable data proof, not only visual chips.
```ts
type 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>;
};
};
```
Examples:
- `未关闭 Bug / 18 / Bug Domain / click → Bug list filtered by open statuses`
- `阻塞任务 / 6 / DevTask Domain / click → DevTask list filtered by blocked`
- `测试通过率 / 62% / TestCase Domain / click → test case detail list`
## Unified ChartSpec
ChartSpec is platform-owned and renderer-agnostic. It is not an ECharts option object.
```ts
type 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';
};
```
Frontend `ChartRenderer` converts Unified ChartSpec into ECharts options. This keeps the contract stable if the renderer changes later.
## Apple Vision Chart Design System
### Overall
- White or deep neutral background.
- Large whitespace and clear hierarchy.
- Large rounded cards, light borders, soft shadows, and translucent material.
- Content first; numbers and conclusions lead the page.
- No blue gradient dashboard, no dense big-screen layout, no rainbow metrics, no 3D shine.
### Number Cards
The number is the hero.
Structure:
```text
238
已完成需求
↑ 12%
较上周期
```
Rules:
- Big number, small label.
- Comparison line is secondary.
- Risk or negative trend may use orange/red; normal values use a restrained single accent color.
### Line Charts
Use Apple Stocks-like style:
- Smooth curve.
- Subtle area gradient.
- Minimal axes and grid.
- Highlight peaks, drops, or anomaly points automatically.
- Do not draw heavy traditional grid lines.
### Bar Charts
Use horizontal bars for ranking:
```text
研发 ████████
产品 ██████
测试 ████
```
Rules:
- Single color by default.
- Rounded bar caps.
- Soft entrance animation.
- Values aligned for scanning.
- Use Top N; do not render 100 members in one chart.
### Donut and Composition Charts
- Use only when part-to-whole composition matters.
- Limit slices to Top 8 plus "其他".
- Use restrained semantic colors. Avoid five-color decoration unless categories need separation.
### Motion
- Entrance animation is soft and short.
- Tooltip follows pointer naturally.
- Hover reveals detail without moving layout.
- No dramatic loading animation or distracting particle effects.
## Insight, Report, and Follow-Ups
### Insight Card
Insight Card comes before the chart. It is one concise conclusion, such as:
```text
产品部延期事项最多,占当前延期事项的 49%。
```
It should include:
- Main conclusion.
- Main metric value.
- Trend or comparison if available.
- Confidence level if semantic or data confidence is not high.
```ts
type 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';
};
```
### Report Structure
Reports use a fixed structure:
1. Summary
2. Key Findings
3. Evidence
4. Suggestions
5. Data Scope
AI may write the prose, but it must only use Metric Result data, Evidence, and Data Scope.
```ts
type AnalysisReport = {
summary: string;
keyFindings: string[];
evidence: EvidenceItem[];
suggestions: string[];
dataScope: {
timeDescription: string;
permissionDescription: string;
metricFormulaDescription: string;
generatedAt: string;
};
};
```
### Follow-Up Types
```ts
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';
};
```
First version follow-ups are read-only:
- Follow-up question: "为什么延期?"
- Drilldown: "查看负责人", "查看 Bug 明细", "切到版本维度"
- Export: "导出分析报告"
Business-mutating follow-ups such as creating meetings, assigning owners, or changing plans are excluded from the MVP. They require explicit user confirmation and domain API design in a later phase.
## Data Confidence
Data confidence is separate from semantic confidence.
Display levels:
- `sufficient`: enough rows, current data, reliable plan dates or status facts.
- `partial`: some missing dates, sparse records, or incomplete evidence.
- `insufficient`: no usable rows or too few rows for the requested comparison.
Examples that lower data confidence:
- No planned end dates.
- Missing work activities.
- No historical baseline for comparison.
- Small sample size.
- Current user only has partial scope.
## No Data Strategy
If there is no data, do not let AI invent an explanation.
Return a structured no-data result:
- State what was requested.
- State why no result can be computed.
- Show the applied scope and time range.
- Suggest valid alternatives.
Example:
```text
没有找到 2025 年测试通过率数据。
可以改看 2026 年、当前版本测试完成情况,或按项目查看已有测试用例。
```
## API Shape
Initial endpoint:
```text
POST /api/v1/ai/analysis
```
Request:
```ts
type AnalysisRequest = {
question: string;
context?: {
surface: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
productId?: string;
projectId?: string;
versionId?: string;
};
};
```
Response:
```ts
type AnalysisResponse =
| {
ok: true;
plan: AnalysisPlan;
metricResult: MetricResult;
insight: InsightCard;
chart: UnifiedChartSpec;
report: AnalysisReport;
followUps: FollowUp[];
}
| {
ok: false;
code:
| 'NO_PERMISSION'
| 'UNSUPPORTED_ANALYSIS'
| 'AMBIGUOUS_INTENT'
| 'NO_DATA'
| 'AI_UNAVAILABLE'
| 'INVALID_PLAN';
message: string;
clarificationOptions?: Array<{ label: string; prompt: string }>;
dataScope?: DataScope;
};
```
## Error Handling
- `NO_PERMISSION`: user asked for a scope outside authorized data.
- `UNSUPPORTED_ANALYSIS`: metric or dimension is not in the catalog.
- `AMBIGUOUS_INTENT`: semantic confidence is too low and multiple interpretations are plausible.
- `NO_DATA`: plan is valid but no rows exist in scope.
- `AI_UNAVAILABLE`: AI Planning or AI report generation failed; templates and deterministic reports can still work when possible.
- `INVALID_PLAN`: AI proposed or user requested an invalid plan after validation.
Fallback order:
1. Template or deterministic strategy with deterministic report.
2. AI report over Metric Result when provider is available.
3. Deterministic report summary if AI is unavailable.
4. Structured error/no-data response.
## Testing Strategy
### Unit Tests
- Semantic Layer maps known phrases to expected concepts and confidence levels.
- Metric Catalog rejects unknown metrics, unsupported dimensions, unsupported analysis types, and deprecated metrics.
- Analysis Strategy chooses Template before Rule Composition before AI Planning.
- Rule Composition produces deterministic plans for examples such as delay reason trend.
- Analysis Plan Processor validates and normalizes time ranges, scope, Top N, dimensions, and metric versions.
- No Data Strategy returns structured no-data responses.
- ChartSpec Builder emits renderer-agnostic ChartSpec, not raw ECharts options.
- Evidence items include source domain and drilldown filters when available.
### Service Tests
- Normal member cannot query all-company management scope.
- Project Owner/Admin can query managed project scope.
- Version detail context restricts analysis to current version.
- Current-state risk queries do not use last 30 days by default.
- Trend/effort/throughput queries use last 30 days when no range is specified.
- Comparison queries use last 30 days versus previous 30 days when no baseline is specified.
### Frontend Tests
- AI Assistant renders Insight Card, chart, report, evidence, and follow-ups.
- Product/project/version context passes correct IDs to the analysis endpoint.
- ChartRenderer converts Unified ChartSpec to ECharts options through a single renderer boundary.
- Long labels and Top N bars do not overflow on desktop or mobile.
- No-data and no-permission states render as clear non-chart answers.
### Visual QA
- Verify Apple Vision rules on light and dark backgrounds.
- Verify line charts have smooth curves, area gradient, minimal axes, and anomaly markers.
- Verify horizontal bars are single-color, rounded, and animated softly.
- Verify number cards make the number the primary visual element.
## Documentation Updates
This design affects:
- `docs/architecture.md`: add Business Analysis Agent layer.
- `docs/decisions.md`: add decision for Semantic Layer, Analysis Strategy, Metric Catalog, and unified ChartSpec.
- `docs/workflow.md`: add business analysis workflow.
- `docs/roadmap.md`: add V3 Business Analysis Agent stage.
- `docs/agent-spec.md`: add Business Analysis Agent contract summary.

View File

@@ -0,0 +1,238 @@
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;
};

View File

@@ -1,3 +1,4 @@
export * from './enums'; export * from './enums';
export * from './types'; export * from './types';
export * from './agent'; export * from './agent';
export * from './analysis';

48
pnpm-lock.yaml generated
View File

@@ -102,6 +102,12 @@ importers:
'@ftb/shared': '@ftb/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@18.3.1)
lucide-react: lucide-react:
specifier: ^1.17.0 specifier: ^1.17.0
version: 1.17.0(react@18.3.1) version: 1.17.0(react@18.3.1)
@@ -582,28 +588,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.33': '@next/swc-linux-arm64-musl@14.2.33':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@14.2.33': '@next/swc-linux-x64-gnu@14.2.33':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@14.2.33': '@next/swc-linux-x64-musl@14.2.33':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.33': '@next/swc-win32-arm64-msvc@14.2.33':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
@@ -1348,6 +1350,15 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2555,6 +2566,9 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2822,6 +2836,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3013,6 +3030,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zustand@4.5.7: zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'} engines: {node: '>=12.7.0'}
@@ -4428,6 +4448,18 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@18.3.1):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 18.3.1
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.368: {} electron-to-chromium@1.5.368: {}
@@ -5824,6 +5856,8 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {} slash@3.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
@@ -6061,6 +6095,8 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-bom: 3.0.0 strip-bom: 3.0.0
tslib@2.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
turbo@2.9.16: turbo@2.9.16:
@@ -6254,6 +6290,10 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zustand@4.5.7(@types/react@18.3.31)(react@18.3.1): zustand@4.5.7(@types/react@18.3.31)(react@18.3.1):
dependencies: dependencies:
use-sync-external-store: 1.6.0(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1)