diff --git a/apps/server/src/modules/ai/ai.controller.ts b/apps/server/src/modules/ai/ai.controller.ts index 40a8933..4f43c7a 100644 --- a/apps/server/src/modules/ai/ai.controller.ts +++ b/apps/server/src/modules/ai/ai.controller.ts @@ -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 { return this.aiService.decompose(dto); } + + @Post('risk-interpret') + async interpretRisk(@Body() dto: RiskInterpretDto): Promise { + return this.aiService.interpretRisk(dto); + } } diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 7e57b79..957ff29 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -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_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; +function createRiskRequest(patch: Partial = {}): 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() diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 655c8f4..c3684ba 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -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 { + 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 { + 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 { if (!url || !url.startsWith('http')) { throw new Error('原型链接无效'); diff --git a/apps/server/src/modules/ai/dto/risk-interpret.dto.ts b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts new file mode 100644 index 0000000..093eeb0 --- /dev/null +++ b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts @@ -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; +} diff --git a/apps/server/src/modules/ai/prompts/risk-interpret.ts b/apps/server/src/modules/ai/prompts/risk-interpret.ts new file mode 100644 index 0000000..7f6216e --- /dev/null +++ b/apps/server/src/modules/ai/prompts/risk-interpret.ts @@ -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, +}; diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 8dcaa5c..4676b8f 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -114,6 +114,74 @@ export interface AgentDecomposeError { 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 配置 ─────────────────────────── */ export type AiProviderFormat = 'anthropic' | 'openai';