feat(小宝预警): 增加 AI 风险解读接口

This commit is contained in:
Script Generator
2026-06-29 18:59:11 +08:00
parent 7b1a48f1f7
commit 064617aba6
6 changed files with 596 additions and 1 deletions

View File

@@ -1,7 +1,13 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service';
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')
export class AiController {
@@ -11,4 +17,9 @@ export class AiController {
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
@Post('risk-interpret')
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto);
}
}

View File

@@ -1,11 +1,230 @@
import { AiService } from './ai.service';
import type { AiGatewayService } from './ai-gateway.service';
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 =
'Anthropic 未通过 tool_use 返回结果stop_reason=tool_usecontent=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', () => {
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 () => {
const callTool = jest
.fn()

View File

@@ -6,6 +6,12 @@ import {
DECOMPOSE_TOOL_DESCRIPTION,
DECOMPOSE_TOOL_INPUT_SCHEMA,
} 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 type {
AgentDecomposeRequest,
@@ -13,6 +19,10 @@ import type {
AgentDecomposeError,
AgentDecomposeResult,
AgentDecomposeTarget,
AgentRiskInsight,
AgentRiskInterpretError,
AgentRiskInterpretRequest,
AgentRiskInterpretResponse,
} from '@ftb/shared';
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> {
if (!url || !url.startsWith('http')) {
throw new Error('原型链接无效');

View 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;
}

View 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,
};