diff --git a/apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts b/apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts new file mode 100644 index 0000000..f0df96f --- /dev/null +++ b/apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/analysis-plan-processor.ts b/apps/server/src/modules/ai/analysis/analysis-plan-processor.ts new file mode 100644 index 0000000..4372937 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/analysis-plan-processor.ts @@ -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; + 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 { + 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', + }; +} diff --git a/apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts b/apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts new file mode 100644 index 0000000..d863b70 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts @@ -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', + }); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts b/apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts new file mode 100644 index 0000000..f2e00d5 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts @@ -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 }; +} diff --git a/apps/server/src/modules/ai/analysis/metric-catalog.spec.ts b/apps/server/src/modules/ai/analysis/metric-catalog.spec.ts new file mode 100644 index 0000000..443e2cc --- /dev/null +++ b/apps/server/src/modules/ai/analysis/metric-catalog.spec.ts @@ -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'); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/metric-catalog.ts b/apps/server/src/modules/ai/analysis/metric-catalog.ts new file mode 100644 index 0000000..b4c1b0b --- /dev/null +++ b/apps/server/src/modules/ai/analysis/metric-catalog.ts @@ -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; +}