Compare commits
11 Commits
32aaf53b26
...
1e6fb0c7aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e6fb0c7aa | ||
|
|
6d2999085e | ||
|
|
c724446bbd | ||
|
|
56c8f59d13 | ||
|
|
7cd18dabac | ||
|
|
889a34cfa1 | ||
|
|
89ee44422b | ||
|
|
04db0000b5 | ||
|
|
b6b7ebf44f | ||
|
|
74c55df59b | ||
|
|
5933b84bf6 |
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal 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'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal file
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal file
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal file
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal file
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal 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' },
|
||||||
|
];
|
||||||
|
}
|
||||||
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal file
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal file
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal file
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal 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;
|
||||||
|
}
|
||||||
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal 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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal 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))));
|
||||||
|
}
|
||||||
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal 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 天');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal 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}`;
|
||||||
|
}
|
||||||
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal file
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal file
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal 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;
|
||||||
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal file
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
19
apps/web/components/analysis/AnalysisChart.tsx
Normal file
19
apps/web/components/analysis/AnalysisChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal file
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal file
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/components/analysis/AnalysisReport.tsx
Normal file
37
apps/web/components/analysis/AnalysisReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal file
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/analysis/EvidenceList.tsx
Normal file
25
apps/web/components/analysis/EvidenceList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/components/analysis/FollowUpActions.tsx
Normal file
23
apps/web/components/analysis/FollowUpActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/web/components/analysis/InsightCard.tsx
Normal file
31
apps/web/components/analysis/InsightCard.tsx
Normal 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' ? '部分数据' : '数据不足';
|
||||||
|
}
|
||||||
26
apps/web/lib/analysis-api.test.ts
Normal file
26
apps/web/lib/analysis-api.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
6
apps/web/lib/analysis-api.ts
Normal file
6
apps/web/lib/analysis-api.ts
Normal 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 });
|
||||||
|
}
|
||||||
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal file
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
124
apps/web/lib/analysis-chart-renderer.ts
Normal 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;
|
||||||
|
}
|
||||||
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal file
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 扩展能力。
|
||||||
|
|||||||
3026
docs/superpowers/plans/2026-07-08-business-analysis-agent.md
Normal file
3026
docs/superpowers/plans/2026-07-08-business-analysis-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
238
packages/shared/src/analysis.ts
Normal file
238
packages/shared/src/analysis.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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
48
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user