feat(ai-analysis): 添加语义层和分析计划处理器

This commit is contained in:
2026-07-08 21:06:02 +08:00
parent b6b7ebf44f
commit 04db0000b5
6 changed files with 468 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';
describe('analysis plan processor', () => {
it('normalizes trend plans without a time range to last 30 days', () => {
const plan = normalizeAnalysisPlan(
{
metricId: 'requirement_completion_count',
analysisType: 'trend',
dimensions: ['day'],
scope: { type: 'self', userId: 'm-1' },
filters: {},
},
new Date('2026-07-08T12:00:00.000Z'),
);
expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
expect(plan.timeRange).toEqual({
start: '2026-06-09T00:00:00.000Z',
end: '2026-07-08T23:59:59.999Z',
policy: 'last_30_days',
});
});
it('keeps current-state risk plans without a time range', () => {
const plan = normalizeAnalysisPlan(
{
metricId: 'version_risk_score',
analysisType: 'ranking',
dimensions: ['version'],
scope: { type: 'managed_projects', projectIds: ['project-1'] },
filters: {},
},
new Date('2026-07-08T12:00:00.000Z'),
);
expect(plan.timeRange).toBeUndefined();
expect(plan.limit).toBe(10);
});
it('rejects unsupported metric dimensions', () => {
const plan = normalizeAnalysisPlan({
metricId: 'bug_severity_count',
analysisType: 'ranking',
dimensions: ['department'],
scope: { type: 'self', userId: 'm-1' },
filters: {},
});
expect(() => validateAnalysisPlan(plan)).toThrow(
'Unsupported dimension department for metric bug_severity_count',
);
});
});

View File

@@ -0,0 +1,89 @@
import { BadRequestException } from '@nestjs/common';
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';
export interface AnalysisPlanDraft {
metricId: MetricId;
metricVersion?: number;
analysisType: AnalysisType;
dimensions: DimensionId[];
scope: DataScope;
filters?: Record<string, string | number | boolean | string[] | number[]>;
timeRange?: { start: string; end: string; policy?: TimePolicy };
limit?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
const MAX_TOP_N = 20;
const DEFAULT_TOP_N = 10;
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
const metric = getMetricDefinition(input.metricId, input.metricVersion);
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
const timeRange = input.timeRange
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
: needsDefaultTime
? last30Days(now)
: undefined;
return {
metricRef: { metricId: metric.metricId, version: metric.version },
analysisType: input.analysisType,
dimensions: input.dimensions.length > 0
? input.dimensions
: metric.defaultDimension
? [metric.defaultDimension]
: [],
filters: input.filters ?? {},
scope: input.scope,
...(timeRange ? { timeRange } : {}),
limit: normalizeLimit(input.limit, input.analysisType),
...(input.sort ? { sort: input.sort } : {}),
};
}
export function validateAnalysisPlan(plan: AnalysisPlan): void {
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric || metric.status !== 'active') {
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
}
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
}
for (const dimension of plan.dimensions) {
if (!metric.supportedDimensions.includes(dimension)) {
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
}
}
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
}
if (plan.timeRange) {
const start = Date.parse(plan.timeRange.start);
const end = Date.parse(plan.timeRange.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
throw new BadRequestException('Invalid time range');
}
}
}
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
const value = limit ?? DEFAULT_TOP_N;
return Math.min(Math.max(1, value), MAX_TOP_N);
}
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
const end = new Date(now);
end.setUTCHours(23, 59, 59, 999);
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - 29);
start.setUTCHours(0, 0, 0, 0);
return {
start: start.toISOString(),
end: end.toISOString(),
policy: 'last_30_days',
};
}

View File

@@ -0,0 +1,29 @@
import { parseSemanticIntent } from './analysis-semantic-layer';
describe('analysis semantic layer', () => {
it('maps busy wording to workload with high confidence', () => {
expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
concept: 'workload',
semanticConfidence: 'high',
metricId: 'department_workload',
analysisType: 'ranking',
});
});
it('maps pressure wording to workload pressure with medium confidence', () => {
expect(parseSemanticIntent('谁压力最大')).toMatchObject({
concept: 'work_pressure',
semanticConfidence: 'medium',
metricId: 'member_pending_work',
analysisType: 'ranking',
});
});
it('keeps project risk as current-state release risk', () => {
expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
concept: 'release_risk',
metricId: 'version_risk_score',
timePolicy: 'current_state',
});
});
});

View File

@@ -0,0 +1,66 @@
import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';
export type SemanticConcept =
| 'workload'
| 'work_pressure'
| 'release_risk'
| 'delay'
| 'delivery_efficiency'
| 'quality_risk'
| 'requirement_completion'
| 'unknown';
export interface SemanticIntent {
concept: SemanticConcept;
metricId: MetricId;
analysisType: AnalysisType;
timePolicy: TimePolicy;
semanticConfidence: 'high' | 'medium' | 'low';
}
export function parseSemanticIntent(question: string): SemanticIntent {
const text = question.trim().toLowerCase();
if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
}
if (/(压力|压着|吃紧)/.test(text)) {
return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
}
if (/(忙|负载|待办|任务最多)/.test(text)) {
const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text)
? 'department_workload'
: 'member_pending_work';
return intent('workload', metricId, 'ranking', 'current_state', 'high');
}
if (/(延期|逾期|超期)/.test(text)) {
return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
}
if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
}
if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
return intent(
'quality_risk',
metricId,
analysisType,
metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state',
'high',
);
}
if (/(加班|投入|工时)/.test(text)) {
return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
}
return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
}
function intent(
concept: SemanticConcept,
metricId: MetricId,
analysisType: AnalysisType,
timePolicy: TimePolicy,
semanticConfidence: 'high' | 'medium' | 'low',
): SemanticIntent {
return { concept, metricId, analysisType, timePolicy, semanticConfidence };
}

View File

@@ -0,0 +1,21 @@
import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';
describe('metric catalog', () => {
it('defines versioned active metrics used by the MVP templates', () => {
const metric = getMetricDefinition('version_risk_score');
expect(metric).toMatchObject({
metricId: 'version_risk_score',
version: 1,
status: 'active',
defaultTimePolicy: 'current_state',
});
});
it('does not expose raw chart renderer contracts', () => {
const charts = listMetricDefinitions().map((item) => item.defaultChart);
expect(charts).toContain('horizontal_bar');
expect(charts).not.toContain('echarts_option');
});
});

View File

@@ -0,0 +1,210 @@
import type { MetricDefinition, MetricId } from '@ftb/shared';
export const METRIC_CATALOG: MetricDefinition[] = [
{
metricId: 'version_risk_score',
version: 1,
name: '版本风险分',
description: '基于小宝风险摘要的当前版本风险排行。',
formula: 'xiaobao_risk_summaries.risk_score',
owner: 'xiaobao',
supportedDimensions: ['version', 'project', 'product'],
supportedAnalysisTypes: ['ranking', 'summary'],
defaultChart: 'horizontal_bar',
defaultDimension: 'version',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'completion_trend',
version: 1,
name: '完成趋势',
description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
formula: 'count(completed_at or terminal status updated_at) by day',
owner: 'analysis',
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'overdue_item_count',
version: 1,
name: '逾期事项数',
description: '当前超过计划结束时间且未完成的事项数量。',
formula: 'count(open items where due_at < now)',
owner: 'analysis',
supportedDimensions: ['version', 'project', 'member', 'department'],
supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'version',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'requirement_status_count',
version: 1,
name: '需求状态分布',
description: '按需求状态统计需求数量。',
formula: 'count(requirements) by status',
owner: 'requirement',
supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
defaultChart: 'donut',
defaultDimension: 'requirement_status',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'requirement_completion_count',
version: 1,
name: '需求完成数量',
description: '按时间统计进入 released 或 closed 的需求数量。',
formula: "count(requirements where status in ('released','closed')) by time bucket",
owner: 'requirement',
supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'requirement_source_count',
version: 1,
name: '需求来源/类型占比',
description: '按需求来源或类型统计需求数量。',
formula: 'count(requirements) by source_type or type',
owner: 'requirement',
supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
supportedAnalysisTypes: ['composition', 'distribution'],
defaultChart: 'donut',
defaultDimension: 'requirement_source',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'department_workload',
version: 1,
name: '部门负载',
description: '按部门统计当前未完成事项数量。',
formula: 'count(open work items grouped by user.department_id)',
owner: 'management',
supportedDimensions: ['department'],
supportedAnalysisTypes: ['ranking', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'department',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'member_pending_work',
version: 1,
name: '成员待办',
description: '按成员统计当前未完成事项数量。',
formula: 'count(open work items grouped by assignee or owner)',
owner: 'management',
supportedDimensions: ['member', 'role', 'project', 'version'],
supportedAnalysisTypes: ['ranking', 'breakdown'],
defaultChart: 'horizontal_bar',
defaultDimension: 'member',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'member_effort_hours',
version: 1,
name: '成员投入工时',
description: '按成员统计工作活动、工时记录和加班投入。',
formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
owner: 'management',
supportedDimensions: ['member', 'department', 'project', 'version'],
supportedAnalysisTypes: ['ranking', 'comparison'],
defaultChart: 'horizontal_bar',
defaultDimension: 'member',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'bug_severity_count',
version: 1,
name: 'Bug 严重度分布',
description: '按严重度统计未关闭 Bug 数量。',
formula: "count(bugs where status not in ('closed','rejected')) by severity",
owner: 'quality',
supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
defaultChart: 'stacked_horizontal_bar',
defaultDimension: 'bug_severity',
defaultTimePolicy: 'current_state',
status: 'active',
},
{
metricId: 'test_pass_rate',
version: 1,
name: '测试通过率',
description: '按时间统计测试通过用例占已执行用例比例。',
formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
owner: 'quality',
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'day',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'overtime_reason_hours',
version: 1,
name: '加班原因工时',
description: '按加班原因统计加班时长。',
formula: 'sum(overtime_records.hours) by reason',
owner: 'management',
supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
supportedAnalysisTypes: ['composition', 'ranking'],
defaultChart: 'donut',
defaultDimension: 'delay_reason',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'delay_rate',
version: 1,
name: '延期率',
description: '按范围统计逾期事项占全部计划事项比例。',
formula: 'overdue_count / planned_item_count',
owner: 'analysis',
supportedDimensions: ['month', 'project', 'version', 'department'],
supportedAnalysisTypes: ['trend', 'comparison'],
defaultChart: 'line_area',
defaultDimension: 'month',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
{
metricId: 'delay_reason_count',
version: 1,
name: '延期原因数量',
description: '按原因统计延期相关需求变更或加班原因。',
formula: 'count(requirement.change_reason) + count(overtime.reason)',
owner: 'analysis',
supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
defaultChart: 'line_area',
defaultDimension: 'delay_reason',
defaultTimePolicy: 'last_30_days',
status: 'active',
},
];
export function listMetricDefinitions(): MetricDefinition[] {
return METRIC_CATALOG.slice();
}
export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
return candidates.find((item) => item.status === 'active') ?? null;
}