feat(ai-analysis): 添加语义层和分析计划处理器
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user