feat(小宝预警): 增加 AI 风险解读接口
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { DecomposeDto } from './dto/decompose.dto';
|
import { DecomposeDto } from './dto/decompose.dto';
|
||||||
import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared';
|
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
||||||
|
import type {
|
||||||
|
AgentDecomposeResponse,
|
||||||
|
AgentDecomposeError,
|
||||||
|
AgentRiskInterpretResponse,
|
||||||
|
AgentRiskInterpretError,
|
||||||
|
} from '@ftb/shared';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
@@ -11,4 +17,9 @@ export class AiController {
|
|||||||
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
||||||
return this.aiService.decompose(dto);
|
return this.aiService.decompose(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('risk-interpret')
|
||||||
|
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||||
|
return this.aiService.interpretRisk(dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,230 @@
|
|||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import type { AiGatewayService } from './ai-gateway.service';
|
import type { AiGatewayService } from './ai-gateway.service';
|
||||||
import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
|
import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
|
||||||
|
import { RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
||||||
|
import type { AgentRiskInterpretRequest } from '@ftb/shared';
|
||||||
|
|
||||||
const missingToolUseMessage =
|
const missingToolUseMessage =
|
||||||
'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
|
'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
|
||||||
|
|
||||||
|
function createRiskRequest(patch: Partial<AgentRiskInterpretRequest> = {}): AgentRiskInterpretRequest {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
riskScore: 70,
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
delayDays: 1,
|
||||||
|
confidence: 80,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 4,
|
||||||
|
openBugCount: 3,
|
||||||
|
criticalBugCount: 3,
|
||||||
|
failedTestCount: 1,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 1,
|
||||||
|
},
|
||||||
|
trendSummary: 'risk rising',
|
||||||
|
reasons: [{ key: 'critical_bug', label: 'Critical Bug', severity: 'critical', detail: '3 P1 bugs' }],
|
||||||
|
silentRisks: [],
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [],
|
||||||
|
todayRisks: ['tests failed'],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 1,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('AiService', () => {
|
describe('AiService', () => {
|
||||||
|
it('interprets version risk through the active provider', async () => {
|
||||||
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_risk_interpretation',
|
||||||
|
toolInput: {
|
||||||
|
summary: '预计延期',
|
||||||
|
why: ['P1 Bug 增加'],
|
||||||
|
forecast: '预计 2026-07-04 可发',
|
||||||
|
recommendedReleaseWindow: '2026-07-04 之后',
|
||||||
|
suggestedActions: ['优先修复 P1 Bug'],
|
||||||
|
ownerHints: ['测试负责人同步失败用例'],
|
||||||
|
generatedAt: '2026-06-29T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
rawModel: 'test-model',
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 20,
|
||||||
|
});
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
|
||||||
|
const result = await service.interpretRisk({
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
riskScore: 70,
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
delayDays: 1,
|
||||||
|
confidence: 80,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 4,
|
||||||
|
openBugCount: 3,
|
||||||
|
criticalBugCount: 3,
|
||||||
|
failedTestCount: 1,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 1,
|
||||||
|
},
|
||||||
|
trendSummary: '风险上升',
|
||||||
|
reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }],
|
||||||
|
silentRisks: [],
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [],
|
||||||
|
todayRisks: ['测试不通过'],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.result.summary).toBe('预计延期');
|
||||||
|
expect(callTool).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 3000,
|
||||||
|
tool: expect.objectContaining({ name: 'submit_risk_interpretation' }),
|
||||||
|
}));
|
||||||
|
expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns PARSE_ERROR when risk provider returns malformed payload', async () => {
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool: jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_risk_interpretation',
|
||||||
|
toolInput: null,
|
||||||
|
rawModel: 'test-model',
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 20,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
|
||||||
|
const result = await service.interpretRisk(createRiskRequest());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns PARSE_ERROR when risk provider returns the wrong tool name', async () => {
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool: jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_decompose',
|
||||||
|
toolInput: {
|
||||||
|
summary: 'wrong tool',
|
||||||
|
why: ['bad'],
|
||||||
|
forecast: 'bad',
|
||||||
|
suggestedActions: ['bad'],
|
||||||
|
ownerHints: ['bad'],
|
||||||
|
generatedAt: '2026-06-29T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
rawModel: 'test-model',
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 20,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
|
||||||
|
const result = await service.interpretRisk(createRiskRequest());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns PARSE_ERROR when risk insight arrays contain non-string values', async () => {
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool: jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_risk_interpretation',
|
||||||
|
toolInput: {
|
||||||
|
summary: 'bad array',
|
||||||
|
why: [123],
|
||||||
|
forecast: 'bad',
|
||||||
|
suggestedActions: ['bad'],
|
||||||
|
ownerHints: ['bad'],
|
||||||
|
generatedAt: '2026-06-29T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
rawModel: 'test-model',
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 20,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
|
||||||
|
const result = await service.interpretRisk(createRiskRequest());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates risk daily evidence array items as strings', () => {
|
||||||
|
const dto = plainToInstance(RiskInterpretDto, {
|
||||||
|
...createRiskRequest(),
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [],
|
||||||
|
todayRisks: [123],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
|
||||||
|
expect(JSON.stringify(errors)).toContain('todayRisks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires risk signals and daily evidence objects in DTO validation', () => {
|
||||||
|
const { signals: _signals, dailyEvidence: _dailyEvidence, ...payload } = createRiskRequest();
|
||||||
|
const dto = plainToInstance(RiskInterpretDto, payload);
|
||||||
|
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
const serialized = JSON.stringify(errors);
|
||||||
|
|
||||||
|
expect(serialized).toContain('signals');
|
||||||
|
expect(serialized).toContain('dailyEvidence');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids extra fields in risk interpretation tool schema', () => {
|
||||||
|
expect((RISK_INTERPRET_TOOL_INPUT_SCHEMA as any).additionalProperties).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns PARSE_ERROR when provider reports invalid tool arguments', async () => {
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool: jest.fn().mockRejectedValue(new Error('OpenAI tool_call arguments is not valid JSON')),
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
|
||||||
|
const result = await service.interpretRisk(createRiskRequest());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
|
});
|
||||||
|
|
||||||
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
||||||
const callTool = jest
|
const callTool = jest
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
DECOMPOSE_TOOL_DESCRIPTION,
|
DECOMPOSE_TOOL_DESCRIPTION,
|
||||||
DECOMPOSE_TOOL_INPUT_SCHEMA,
|
DECOMPOSE_TOOL_INPUT_SCHEMA,
|
||||||
} from './prompts/decompose';
|
} from './prompts/decompose';
|
||||||
|
import {
|
||||||
|
RISK_INTERPRET_SYSTEM_PROMPT,
|
||||||
|
RISK_INTERPRET_TOOL_NAME,
|
||||||
|
RISK_INTERPRET_TOOL_DESCRIPTION,
|
||||||
|
RISK_INTERPRET_TOOL_INPUT_SCHEMA,
|
||||||
|
} from './prompts/risk-interpret';
|
||||||
import { buildPrototypeContext } from './prototype-context';
|
import { buildPrototypeContext } from './prototype-context';
|
||||||
import type {
|
import type {
|
||||||
AgentDecomposeRequest,
|
AgentDecomposeRequest,
|
||||||
@@ -13,6 +19,10 @@ import type {
|
|||||||
AgentDecomposeError,
|
AgentDecomposeError,
|
||||||
AgentDecomposeResult,
|
AgentDecomposeResult,
|
||||||
AgentDecomposeTarget,
|
AgentDecomposeTarget,
|
||||||
|
AgentRiskInsight,
|
||||||
|
AgentRiskInterpretError,
|
||||||
|
AgentRiskInterpretRequest,
|
||||||
|
AgentRiskInterpretResponse,
|
||||||
} from '@ftb/shared';
|
} from '@ftb/shared';
|
||||||
|
|
||||||
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
||||||
@@ -129,6 +139,119 @@ export class AiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async interpretRisk(req: AgentRiskInterpretRequest): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let provider;
|
||||||
|
let model: string;
|
||||||
|
try {
|
||||||
|
provider = await this.gateway.getActiveProvider();
|
||||||
|
model = await this.gateway.getActiveModel();
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e?.message || '未配置 AI 提供商',
|
||||||
|
code: 'NO_PROVIDER',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolResp = await provider.callTool({
|
||||||
|
systemPrompt: RISK_INTERPRET_SYSTEM_PROMPT,
|
||||||
|
userPrompt: `请基于以下小宝预警规则结果生成解释,禁止编造新事实:\n${JSON.stringify(req, null, 2)}`,
|
||||||
|
tool: {
|
||||||
|
name: RISK_INTERPRET_TOOL_NAME,
|
||||||
|
description: RISK_INTERPRET_TOOL_DESCRIPTION,
|
||||||
|
inputSchema: RISK_INTERPRET_TOOL_INPUT_SCHEMA,
|
||||||
|
},
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 3000,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
const result = this.parseRiskInsightToolResponse(toolResp);
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'AI 返回结构不符合预期',
|
||||||
|
code: 'PARSE_ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
meta: {
|
||||||
|
model: toolResp.rawModel || model,
|
||||||
|
inputTokens: toolResp.inputTokens,
|
||||||
|
outputTokens: toolResp.outputTokens,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
if (this.isProviderParseError(e)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e?.message || 'AI 返回结构不符合预期',
|
||||||
|
code: 'PARSE_ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.logger.error(`AI 风险解读调用失败: ${e.message}`);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `AI 服务调用失败:${e.message}`,
|
||||||
|
code: 'API_ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRiskInsightToolResponse(toolResp: any): AgentRiskInsight | null {
|
||||||
|
if (!toolResp || toolResp.toolName !== RISK_INTERPRET_TOOL_NAME || !this.isRecord(toolResp.toolInput)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = toolResp.toolInput;
|
||||||
|
const summary = this.readString(input.summary);
|
||||||
|
const forecast = this.readString(input.forecast);
|
||||||
|
const generatedAt = this.readString(input.generatedAt);
|
||||||
|
const why = this.readStringArray(input.why);
|
||||||
|
const suggestedActions = this.readStringArray(input.suggestedActions);
|
||||||
|
const ownerHints = this.readStringArray(input.ownerHints);
|
||||||
|
if (!summary || !forecast || !generatedAt || !why || !suggestedActions || !ownerHints) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insight: AgentRiskInsight = {
|
||||||
|
summary,
|
||||||
|
why,
|
||||||
|
forecast,
|
||||||
|
suggestedActions,
|
||||||
|
ownerHints,
|
||||||
|
generatedAt,
|
||||||
|
};
|
||||||
|
if (input.recommendedReleaseWindow !== undefined) {
|
||||||
|
const recommendedReleaseWindow = this.readString(input.recommendedReleaseWindow);
|
||||||
|
if (!recommendedReleaseWindow) return null;
|
||||||
|
insight.recommendedReleaseWindow = recommendedReleaseWindow;
|
||||||
|
}
|
||||||
|
return insight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStringArray(value: unknown): string[] | null {
|
||||||
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProviderParseError(error: any): boolean {
|
||||||
|
const message = String(error?.message || '');
|
||||||
|
return message.includes('tool_call arguments') && message.includes('JSON');
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPrototype(url: string): Promise<string> {
|
private async fetchPrototype(url: string): Promise<string> {
|
||||||
if (!url || !url.startsWith('http')) {
|
if (!url || !url.startsWith('http')) {
|
||||||
throw new Error('原型链接无效');
|
throw new Error('原型链接无效');
|
||||||
|
|||||||
142
apps/server/src/modules/ai/dto/risk-interpret.dto.ts
Normal file
142
apps/server/src/modules/ai/dto/risk-interpret.dto.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsDefined, IsIn, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
class RiskReasonDto {
|
||||||
|
@IsString()
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@IsIn(['low', 'medium', 'high', 'critical'])
|
||||||
|
severity!: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
detail!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SilentRiskDto {
|
||||||
|
@IsString()
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
days?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
detail!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RiskDailyEvidenceDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
todayDeliveries!: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
todayProgress!: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
todayRisks!: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
progressNotes!: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
needsProgressItems!: string[];
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
recentActivityCount!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastActivityAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RiskSignalsDto {
|
||||||
|
@IsNumber()
|
||||||
|
unfinishedCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
openBugCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
criticalBugCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
failedTestCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
blockedCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
silentRiskCount!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
daysToExpectedRelease?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RiskInterpretDto {
|
||||||
|
@IsString()
|
||||||
|
versionId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
versionName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
riskScore!: number;
|
||||||
|
|
||||||
|
@IsIn(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked'])
|
||||||
|
riskLevel!: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
expectedReleaseDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
forecastReleaseDate?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
delayDays!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
confidence!: number;
|
||||||
|
|
||||||
|
@IsDefined()
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RiskSignalsDto)
|
||||||
|
signals!: RiskSignalsDto;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
trendSummary!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RiskReasonDto)
|
||||||
|
reasons!: RiskReasonDto[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SilentRiskDto)
|
||||||
|
silentRisks!: SilentRiskDto[];
|
||||||
|
|
||||||
|
@IsDefined()
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RiskDailyEvidenceDto)
|
||||||
|
dailyEvidence!: RiskDailyEvidenceDto;
|
||||||
|
}
|
||||||
32
apps/server/src/modules/ai/prompts/risk-interpret.ts
Normal file
32
apps/server/src/modules/ai/prompts/risk-interpret.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const RISK_INTERPRET_TOOL_NAME = 'submit_risk_interpretation';
|
||||||
|
export const RISK_INTERPRET_TOOL_DESCRIPTION = '返回小宝预警的结构化解释';
|
||||||
|
|
||||||
|
export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的“小宝预警”解释助手。
|
||||||
|
|
||||||
|
你只能解释系统给出的规则预测结果和证据,不能编造不存在的数据。
|
||||||
|
你需要用项目经理能理解的中文说明:
|
||||||
|
1. 当前能不能按期发版;
|
||||||
|
2. 为什么有风险;
|
||||||
|
3. 预计延期或建议发版窗口;
|
||||||
|
4. 需要优先处理的动作;
|
||||||
|
5. 哪些负责人或角色需要关注。
|
||||||
|
|
||||||
|
输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。
|
||||||
|
如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。
|
||||||
|
|
||||||
|
输出必须通过 submit_risk_interpretation 工具返回,不要输出自然语言正文或 Markdown。`;
|
||||||
|
|
||||||
|
export const RISK_INTERPRET_TOOL_INPUT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
why: { type: 'array', items: { type: 'string' } },
|
||||||
|
forecast: { type: 'string' },
|
||||||
|
recommendedReleaseWindow: { type: 'string' },
|
||||||
|
suggestedActions: { type: 'array', items: { type: 'string' } },
|
||||||
|
ownerHints: { type: 'array', items: { type: 'string' } },
|
||||||
|
generatedAt: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['summary', 'why', 'forecast', 'suggestedActions', 'ownerHints', 'generatedAt'],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
@@ -114,6 +114,74 @@ export interface AgentDecomposeError {
|
|||||||
code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
|
code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentRiskReason {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRiskInterpretRequest {
|
||||||
|
versionId: string;
|
||||||
|
versionName: string;
|
||||||
|
productName?: string;
|
||||||
|
projectName?: string;
|
||||||
|
riskScore: number;
|
||||||
|
riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
|
||||||
|
expectedReleaseDate?: string | null;
|
||||||
|
forecastReleaseDate?: string;
|
||||||
|
delayDays: number;
|
||||||
|
confidence: number;
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: number;
|
||||||
|
openBugCount: number;
|
||||||
|
criticalBugCount: number;
|
||||||
|
failedTestCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
silentRiskCount: number;
|
||||||
|
daysToExpectedRelease?: number;
|
||||||
|
};
|
||||||
|
trendSummary: string;
|
||||||
|
reasons: AgentRiskReason[];
|
||||||
|
silentRisks: Array<{ key: string; days?: number; detail: string }>;
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: string[];
|
||||||
|
todayProgress: string[];
|
||||||
|
todayRisks: string[];
|
||||||
|
progressNotes: string[];
|
||||||
|
needsProgressItems: string[];
|
||||||
|
recentActivityCount: number;
|
||||||
|
lastActivityAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRiskInsight {
|
||||||
|
summary: string;
|
||||||
|
why: string[];
|
||||||
|
forecast: string;
|
||||||
|
recommendedReleaseWindow?: string;
|
||||||
|
suggestedActions: string[];
|
||||||
|
ownerHints: string[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRiskInterpretResponse {
|
||||||
|
ok: true;
|
||||||
|
result: AgentRiskInsight;
|
||||||
|
meta: {
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRiskInterpretError {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────────────────── AI Provider 配置 ─────────────────────────── */
|
/* ───────────────────────── AI Provider 配置 ─────────────────────────── */
|
||||||
|
|
||||||
export type AiProviderFormat = 'anthropic' | 'openai';
|
export type AiProviderFormat = 'anthropic' | 'openai';
|
||||||
|
|||||||
Reference in New Issue
Block a user