feat(ai-analysis): 接入业务分析接口

This commit is contained in:
2026-07-08 21:49:43 +08:00
parent 7cd18dabac
commit 56c8f59d13
12 changed files with 558 additions and 4 deletions

View File

@@ -1,5 +1,9 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Headers, Post } from '@nestjs/common';
import { CurrentUser } from '../../common/auth/current-user.decorator';
import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.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 { RiskInterpretDto } from './dto/risk-interpret.dto';
import type {
@@ -7,11 +11,15 @@ import type {
AgentDecomposeError,
AgentRiskInterpretResponse,
AgentRiskInterpretError,
AnalysisResponse,
} from '@ftb/shared';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
constructor(
private readonly aiService: AiService,
private readonly businessAnalysisService: BusinessAnalysisService,
) {}
@Post('decompose')
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
@@ -22,4 +30,17 @@ export class AiController {
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto);
}
@Post('analysis')
async analyze(
@Body() dto: AnalysisDto,
@CurrentUser() user: ResolvedCurrentUser | null,
@Headers('x-ftb-user-id') headerUserId?: string,
@Headers('x-user-id') legacyHeaderUserId?: string,
): Promise<AnalysisResponse> {
return this.businessAnalysisService.analyze(dto, {
id: user?.id ?? headerUserId ?? legacyHeaderUserId ?? '',
permissions: dto.permissions ?? [],
});
}
}

View File

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

View File

@@ -0,0 +1,53 @@
import type { AnalysisPlan, AnalysisRequest, DimensionId } from '@ftb/shared';
import { parseSemanticIntent } from './analysis-semantic-layer';
import type { SemanticIntent } from './analysis-semantic-layer';
import { normalizeAnalysisPlan } from './analysis-plan-processor';
import { getMetricDefinition } from './metric-catalog';
export function createAnalysisPlanFromQuestion(
request: AnalysisRequest,
scope: AnalysisPlan['scope'],
now = new Date(),
): { semantic: SemanticIntent; plan: AnalysisPlan } {
const semantic = parseSemanticIntent(request.question);
const dimensions = inferDimensions(request.question, semantic.metricId);
const plan = normalizeAnalysisPlan(
{
metricId: semantic.metricId,
analysisType: semantic.analysisType,
dimensions,
scope,
filters: {},
},
now,
);
return { semantic, plan };
}
function inferDimensions(
question: string,
metricId: AnalysisPlan['metricRef']['metricId'],
): DimensionId[] {
const metric = getMetricDefinition(metricId);
const preferred = inferPreferredDimension(question, metricId);
if (preferred && metric?.supportedDimensions.includes(preferred)) return [preferred];
if (metric?.defaultDimension) return [metric.defaultDimension];
return [];
}
function inferPreferredDimension(
question: string,
metricId: AnalysisPlan['metricRef']['metricId'],
): DimensionId | null {
if (/部门/.test(question)) return 'department';
if (/成员|谁|负责人/.test(question)) return 'member';
if (/产品/.test(question)) return 'product';
if (/项目/.test(question)) return 'project';
if (/月|月份/.test(question)) return 'month';
if (/周/.test(question)) return 'week';
if (metricId === 'version_risk_score') return 'version';
if (metricId === 'bug_severity_count') return 'bug_severity';
if (metricId === 'requirement_status_count') return 'requirement_status';
if (metricId === 'requirement_source_count') return 'requirement_source';
return null;
}

View File

@@ -0,0 +1,88 @@
import { BusinessAnalysisService } from './business-analysis.service';
describe('BusinessAnalysisService', () => {
function makeService() {
const scopeResolver = {
resolveAnalysisScope: jest
.fn()
.mockResolvedValue({ type: 'version', versionId: 'ver-1' }),
};
const metricEngine = {
executeMetric: jest.fn().mockResolvedValue({
metricRef: { metricId: 'version_risk_score', version: 1 },
analysisType: 'ranking',
columns: [{ id: 'label', label: '版本', type: 'string' }],
rows: [{ label: 'V1', value: 88 }],
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
dataScope: { type: 'version', versionId: 'ver-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
}),
};
const reportBuilder = {
build: jest.fn().mockResolvedValue({
summary: 'V1 风险较高。',
keyFindings: ['风险分 88。'],
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
suggestions: ['优先处理阻塞和严重 Bug。'],
dataScope: {
timeDescription: '当前状态',
permissionDescription: '当前版本',
metricFormulaDescription: '小宝风险分',
generatedAt: '2026-07-08T12:00:00.000Z',
},
}),
};
const service = new BusinessAnalysisService(
scopeResolver as any,
metricEngine as any,
reportBuilder as any,
);
return { service, scopeResolver, metricEngine, reportBuilder };
}
it('returns insight, chart, report, evidence, and follow-ups', async () => {
const { service } = makeService();
const result = await service.analyze(
{
question: '这个版本风险怎么样',
context: { surface: 'version_detail', versionId: 'ver-1' },
},
{ id: 'm-1', permissions: [] },
new Date('2026-07-08T12:00:00.000Z'),
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.chart.kind).toBe('horizontal_bar');
expect(result.insight.summary).toContain('V1');
expect(result.report.summary).toContain('风险');
expect(
result.followUps.some(
(item: { type: string }) => item.type === 'question',
),
).toBe(true);
}
});
it('returns NO_DATA when metric result has no rows', async () => {
const { service, metricEngine } = makeService();
metricEngine.executeMetric.mockResolvedValueOnce({
metricRef: { metricId: 'requirement_completion_count', version: 1 },
analysisType: 'trend',
columns: [],
rows: [],
evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
dataScope: { type: 'self', userId: 'm-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
});
await expect(
service.analyze(
{ question: '需求完成趋势' },
{ id: 'm-1', permissions: [] },
new Date('2026-07-08T12:00:00.000Z'),
),
).resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
});
});

View File

@@ -0,0 +1,104 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
import { getMetricDefinition } from './metric-catalog';
import { PermissionScopeResolver } from './permission-scope-resolver';
import { MetricEngine } from './metric-engine';
import { AnalysisReportBuilder } from './report-builder';
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
import { validateAnalysisPlan } from './analysis-plan-processor';
import { buildUnifiedChartSpec } from './chart-spec-builder';
import { buildInsightCard } from './insight-engine';
import { buildFollowUps } from './follow-up-builder';
export interface AnalysisActor {
id: string;
permissions: string[];
}
@Injectable()
export class BusinessAnalysisService {
constructor(
private readonly scopeResolver: PermissionScopeResolver,
private readonly metricEngine: MetricEngine,
private readonly reportBuilder: AnalysisReportBuilder,
) {}
async analyze(
request: AnalysisRequest,
actor: AnalysisActor,
now = new Date(),
): Promise<AnalysisResponse> {
const question = request.question.trim();
if (!question) {
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
}
let scope;
try {
scope = await this.scopeResolver.resolveAnalysisScope({
actorId: actor.id,
permissions: actor.permissions,
context: request.context,
});
} catch (error) {
if (error instanceof ForbiddenException) {
return { ok: false, code: 'NO_PERMISSION', message: '当前用户没有该范围的分析权限。' };
}
throw error;
}
const { semantic, plan } = createAnalysisPlanFromQuestion({ ...request, question }, scope, now);
if (semantic.semanticConfidence === 'low') {
return {
ok: false,
code: 'AMBIGUOUS_INTENT',
message: '这个问题有多种理解,请选择一个分析方向。',
clarificationOptions: [
{ label: '成员负载', prompt: '分析成员待办排行' },
{ label: '版本风险', prompt: '分析版本风险排行' },
],
dataScope: scope,
};
}
try {
validateAnalysisPlan(plan);
} catch (error) {
if (error instanceof BadRequestException) {
return {
ok: false,
code: 'INVALID_PLAN',
message: error.message,
dataScope: scope,
};
}
throw error;
}
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
if (!metric) {
return {
ok: false,
code: 'UNSUPPORTED_ANALYSIS',
message: '当前指标不在分析目录中。',
dataScope: scope,
};
}
const metricResult = await this.metricEngine.executeMetric(plan, now);
if (metricResult.rows.length === 0) {
return {
ok: false,
code: 'NO_DATA',
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
dataScope: scope,
};
}
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
const chart = buildUnifiedChartSpec(metricResult, metric);
const report = await this.reportBuilder.build(metricResult, insight, metric);
const followUps = buildFollowUps(metricResult, plan);
return { ok: true, plan, metricResult, insight, chart, report, followUps };
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';
export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
const top = result.rows[0];
const baseQuestion = top?.label ? `为什么${String(top.label)}最高?` : '换一个维度继续分析';
return [
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
{
type: 'drilldown',
label: '查看明细',
target: plan.dimensions[0] ?? 'analysis',
filters: plan.filters,
},
{ type: 'export', label: '导出报告', format: 'pdf' },
];
}

View File

@@ -0,0 +1,23 @@
import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
export function buildInsightCard(
result: MetricResult,
metric: MetricDefinition,
semanticConfidence: InsightCard['semanticConfidence'],
): InsightCard {
const top = result.rows[0];
const topValue = top?.value ?? result.totals?.value ?? 0;
const topLabel = String(top?.label ?? metric.name);
const dataConfidence: InsightCard['dataConfidence'] =
result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
return {
summary:
result.rows.length === 0
? `${metric.name}暂无可分析数据。`
: `${topLabel}${metric.name}中最突出。`,
primaryValue: { label: metric.name, value: topValue },
semanticConfidence,
dataConfidence,
};
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import type { AnalysisReport, DataScope, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
@Injectable()
export class AnalysisReportBuilder {
async build(
result: MetricResult,
insight: InsightCard,
metric: MetricDefinition,
): 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: resultHasTime(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 describeScope(scope: DataScope): string {
if (scope.type === 'system') return '系统管理范围';
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
if (scope.type === 'self') return '与当前用户相关的数据';
if (scope.type === 'product') return `产品范围:${scope.productId}`;
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
return `版本范围:${scope.versionId}`;
}

View File

@@ -0,0 +1,34 @@
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class AnalysisContextDto {
@IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
}
export class AnalysisDto {
@IsString()
question!: string;
@IsOptional()
@ValidateNested()
@Type(() => AnalysisContextDto)
context?: AnalysisContextDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}

View File

@@ -0,0 +1,103 @@
export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';
export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析计划助手。
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL不能绕过权限。
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
当问题无法映射到已暴露能力时,返回 clarificationOptions不要编造指标。
`.trim();
export const ANALYSIS_PLAN_TOOL_DESCRIPTION =
'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';
export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['metricId', 'analysisType', 'dimensions', 'filters'],
properties: {
metricId: {
type: 'string',
enum: [
'version_risk_score',
'completion_trend',
'overdue_item_count',
'requirement_status_count',
'requirement_completion_count',
'requirement_source_count',
'department_workload',
'member_pending_work',
'member_effort_hours',
'bug_severity_count',
'test_pass_rate',
'overtime_reason_hours',
'delay_rate',
'delay_reason_count',
],
},
metricVersion: { type: 'number' },
analysisType: {
type: 'string',
enum: [
'ranking',
'trend',
'comparison',
'distribution',
'composition',
'correlation',
'breakdown',
'summary',
],
},
dimensions: {
type: 'array',
items: {
type: 'string',
enum: [
'product',
'project',
'version',
'requirement_status',
'requirement_type',
'requirement_source',
'department',
'member',
'role',
'month',
'week',
'day',
'bug_severity',
'bug_status',
'test_status',
'delay_reason',
],
},
},
filters: { type: 'object', additionalProperties: true },
timeRange: {
type: 'object',
additionalProperties: false,
required: ['start', 'end'],
properties: {
start: { type: 'string' },
end: { type: 'string' },
policy: {
type: 'string',
enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'],
},
},
},
limit: { type: 'number', minimum: 1, maximum: 20 },
sort: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['field', 'direction'],
properties: {
field: { type: 'string' },
direction: { type: 'string', enum: ['asc', 'desc'] },
},
},
},
},
} as const;

View File

@@ -0,0 +1,22 @@
export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';
export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
你是 FTB 项目管理系统的业务分析报告助手。
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
`.trim();
export const ANALYSIS_REPORT_TOOL_DESCRIPTION =
'Write a structured business analysis report from deterministic metric results without inventing facts.';
export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['summary', 'keyFindings', 'suggestions'],
properties: {
summary: { type: 'string' },
keyFindings: { type: 'array', items: { type: 'string' } },
suggestions: { type: 'array', items: { type: 'string' } },
},
} as const;