feat(ai-analysis): 接入业务分析接口
This commit is contained in:
@@ -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 { 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,15 @@ 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,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('decompose')
|
@Post('decompose')
|
||||||
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
||||||
@@ -22,4 +30,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,
|
||||||
|
@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 ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,23 @@ 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 { 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, 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 {}
|
||||||
|
|||||||
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);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
42
apps/server/src/modules/ai/analysis/report-builder.ts
Normal 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}`;
|
||||||
|
}
|
||||||
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;
|
||||||
Reference in New Issue
Block a user