merge: 合并小宝预警到 master
# Conflicts: # apps/web/lib/xiaobao-risk-trend.test.ts # apps/web/lib/xiaobao-risk.ts # docs/decisions.md
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,267 @@
|
|||||||
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_SYSTEM_PROMPT, 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('removes invalid page refresh suggestions from risk interpretation results', async () => {
|
||||||
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_risk_interpretation',
|
||||||
|
toolInput: {
|
||||||
|
summary: '预计延期',
|
||||||
|
why: ['P1 Bug 增加'],
|
||||||
|
forecast: '预计 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(createRiskRequest());
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.result.suggestedActions).toEqual(['优先修复 P1 Bug 并安排测试复测']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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('tells the risk model not to suggest page refresh or manual update triggers', () => {
|
||||||
|
expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('不要建议刷新页面');
|
||||||
|
expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('系统会自动更新');
|
||||||
|
});
|
||||||
|
|
||||||
|
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,12 +19,23 @@ 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;
|
||||||
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
||||||
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
||||||
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
||||||
|
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||||
|
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||||
|
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
||||||
|
/(手动|主动).*(触发|刷新).*(更新|预警|分析)/i,
|
||||||
|
/(manual|manually).*(refresh|reload|trigger)/i,
|
||||||
|
/(refresh|reload).*(page|browser|xiaobao|warning)/i,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
@@ -129,6 +146,127 @@ 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: this.filterPageRefreshAdvice(suggestedActions),
|
||||||
|
ownerHints: this.filterPageRefreshAdvice(ownerHints),
|
||||||
|
generatedAt,
|
||||||
|
};
|
||||||
|
if (input.recommendedReleaseWindow !== undefined) {
|
||||||
|
const recommendedReleaseWindow = this.readString(input.recommendedReleaseWindow);
|
||||||
|
if (!recommendedReleaseWindow) return null;
|
||||||
|
insight.recommendedReleaseWindow = recommendedReleaseWindow;
|
||||||
|
}
|
||||||
|
return insight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterPageRefreshAdvice(items: string[]): string[] {
|
||||||
|
return items.filter((item) => !this.isPageRefreshAdvice(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPageRefreshAdvice(text: string): boolean {
|
||||||
|
return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
34
apps/server/src/modules/ai/prompts/risk-interpret.ts
Normal file
34
apps/server/src/modules/ai/prompts/risk-interpret.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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 低,必须提醒“预测可信度较低,需补充数据”。
|
||||||
|
系统会自动更新小宝预警结果,用户不需要手动触发页面刷新或重新打开页面。
|
||||||
|
suggestedActions 只能给业务动作,例如修复 Bug、补充日报、复测失败用例、解除阻塞、调整排期或确认负责人;不要建议刷新页面、手动触发更新、重新加载浏览器或等待页面自动更新。
|
||||||
|
|
||||||
|
输出必须通过 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,
|
||||||
|
};
|
||||||
462
apps/web/app/xiaobao-warning/page.tsx
Normal file
462
apps/web/app/xiaobao-warning/page.tsx
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||||||
|
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||||||
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
|
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
||||||
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
|
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
|
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
on_track: '按期',
|
||||||
|
attention: '关注',
|
||||||
|
at_risk: '有风险',
|
||||||
|
likely_delayed: '大概率延期',
|
||||||
|
blocked: '阻塞',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
at_risk: 'border-orange-200 bg-orange-50 text-orange-700',
|
||||||
|
likely_delayed: 'border-red-200 bg-red-50 text-red-700',
|
||||||
|
blocked: 'border-zinc-900 bg-zinc-900 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterOption = { id: string; label: string };
|
||||||
|
|
||||||
|
export default function XiaobaoWarningPage() {
|
||||||
|
return (
|
||||||
|
<RouteGuard permission="xiaobao.warning:view">
|
||||||
|
<XiaobaoWarningContent />
|
||||||
|
</RouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XiaobaoWarningContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||||
|
const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||||
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||||
|
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||||
|
const requestedInsightKeysRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
risks.forEach((risk) => {
|
||||||
|
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
||||||
|
const previousToday = findLatestDailySnapshot(snapshots, snapshot.versionId, snapshot.date);
|
||||||
|
if (!shouldSaveRiskSnapshot(snapshot, previousToday)) return;
|
||||||
|
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
|
||||||
|
if (savedSnapshotKeysRef.current.has(key)) return;
|
||||||
|
savedSnapshotKeysRef.current.add(key);
|
||||||
|
saveSnapshot(snapshot).catch(() => {
|
||||||
|
savedSnapshotKeysRef.current.delete(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [risks, saveSnapshot, snapshots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
risks.forEach((risk) => {
|
||||||
|
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||||
|
if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return;
|
||||||
|
const signature = buildRiskInsightSignature(risk);
|
||||||
|
const key = `${risk.versionId}:${signature}`;
|
||||||
|
if (requestedInsightKeysRef.current.has(key)) return;
|
||||||
|
requestedInsightKeysRef.current.add(key);
|
||||||
|
requestRiskInsight(risk).then((response) => {
|
||||||
|
if (!response.ok) return;
|
||||||
|
saveInsight({
|
||||||
|
versionId: risk.versionId,
|
||||||
|
riskSignature: signature,
|
||||||
|
insight: sanitizeRiskInsight(response.result),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
providerInfo: { model: response.meta.model },
|
||||||
|
}).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
}, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]);
|
||||||
|
|
||||||
|
const risksWithInsight = useMemo(() => risks.map((risk) => {
|
||||||
|
const cached = getReusableInsight(insights, risk);
|
||||||
|
return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk;
|
||||||
|
}), [insights, risks]);
|
||||||
|
|
||||||
|
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
||||||
|
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
||||||
|
const projectOptions = useMemo(
|
||||||
|
() => buildProjectOptions(warningRisks, selectedProductId),
|
||||||
|
[selectedProductId, warningRisks],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProductId && !productOptions.some((option) => option.id === selectedProductId)) {
|
||||||
|
setSelectedProductId('');
|
||||||
|
setSelectedProjectId('');
|
||||||
|
}
|
||||||
|
}, [productOptions, selectedProductId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProjectId && !projectOptions.some((option) => option.id === selectedProjectId)) {
|
||||||
|
setSelectedProjectId('');
|
||||||
|
}
|
||||||
|
}, [projectOptions, selectedProjectId]);
|
||||||
|
|
||||||
|
const filteredRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight, {
|
||||||
|
productId: selectedProductId || undefined,
|
||||||
|
projectId: selectedProjectId || undefined,
|
||||||
|
}), [risksWithInsight, selectedProductId, selectedProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredRisks.length === 0) {
|
||||||
|
setSelectedRiskId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedRiskId || !filteredRisks.some((risk) => risk.versionId === selectedRiskId)) {
|
||||||
|
setSelectedRiskId(filteredRisks[0].versionId);
|
||||||
|
}
|
||||||
|
}, [filteredRisks, selectedRiskId]);
|
||||||
|
|
||||||
|
const selectedRisk = selectedRiskId
|
||||||
|
? filteredRisks.find((risk) => risk.versionId === selectedRiskId) ?? null
|
||||||
|
: null;
|
||||||
|
const avgConfidence = filteredRisks.length > 0
|
||||||
|
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent)]">
|
||||||
|
<TriangleAlert className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-[15px] font-semibold text-[var(--ink)]">小宝预警</h1>
|
||||||
|
<p className="truncate text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{canManage ? '管理视角:全部未结束版本' : '个人视角:我参与的未结束版本'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{canManage && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2 py-1">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
管理权限
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-1.5 text-right">
|
||||||
|
<p>平均置信</p>
|
||||||
|
<p className="text-[15px] font-semibold tabular-nums text-[var(--ink)]">{avgConfidence}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[390px_minmax(0,1fr)]">
|
||||||
|
<aside className="flex min-h-0 flex-col border-b border-[var(--line)] bg-[var(--bg-card)] lg:border-b-0 lg:border-r">
|
||||||
|
<div className="shrink-0 border-b border-[var(--line)] p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<SelectFilter
|
||||||
|
label="产品"
|
||||||
|
value={selectedProductId}
|
||||||
|
allLabel="全部产品"
|
||||||
|
options={productOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedProductId(value);
|
||||||
|
setSelectedProjectId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectFilter
|
||||||
|
label="项目"
|
||||||
|
value={selectedProjectId}
|
||||||
|
allLabel="全部项目"
|
||||||
|
options={projectOptions}
|
||||||
|
onChange={setSelectedProjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
当前显示 {filteredRisks.length} 个风险版本,系统会自动隐藏无风险版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{filteredRisks.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-8 text-center">
|
||||||
|
<p className="text-[13px] font-medium text-[var(--ink-soft)]">暂无小宝预警</p>
|
||||||
|
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">当前筛选范围内没有风险版本。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRisks.map((risk) => (
|
||||||
|
<XiaobaoWarningCard
|
||||||
|
key={risk.versionId}
|
||||||
|
active={risk.versionId === selectedRiskId}
|
||||||
|
risk={risk}
|
||||||
|
onClick={() => setSelectedRiskId(risk.versionId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-h-0 overflow-y-auto bg-[var(--bg)] p-5">
|
||||||
|
{selectedRisk ? (
|
||||||
|
<XiaobaoWarningDetailPanel
|
||||||
|
risk={selectedRisk}
|
||||||
|
onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] text-[13px] text-[var(--ink-muted)]">
|
||||||
|
选择左侧风险版本查看详情
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionRisk; onNavigate: () => void }) {
|
||||||
|
const [detailTab, setDetailTab] = useState<'reasons' | 'silent' | 'evidence'>('reasons');
|
||||||
|
const evidence = risk.dailyEvidence;
|
||||||
|
const evidenceItems = [
|
||||||
|
...(evidence?.todayDeliveries ?? []),
|
||||||
|
...(evidence?.todayProgress ?? []),
|
||||||
|
...(evidence?.todayRisks ?? []),
|
||||||
|
...(evidence?.progressNotes ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[var(--line)] pb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] text-[var(--ink-muted)]">{risk.productName ?? '-'} / {risk.projectName ?? '-'}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-[20px] font-semibold text-[var(--ink)]">{risk.versionName}</h2>
|
||||||
|
<RiskLevelBadge level={risk.riskLevel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="h-9 rounded-lg bg-[var(--accent)] px-4 text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
||||||
|
>
|
||||||
|
打开版本详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<DetailMetric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="剩余工作量" value={formatRemainingWork(risk.remainingWorkHours)} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="小宝建议">
|
||||||
|
{risk.aiInsight ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<p className="flex items-start gap-2 text-[13px] font-medium leading-6 text-[var(--ink)]">
|
||||||
|
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-[var(--accent)]" />
|
||||||
|
<span>{risk.aiInsight.summary}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[13px] leading-6 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
|
||||||
|
{risk.aiInsight.recommendedReleaseWindow && (
|
||||||
|
<p className="mt-3 text-[13px] leading-6 text-[var(--accent)]">{risk.aiInsight.recommendedReleaseWindow}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{risk.aiInsight.suggestedActions.map((action) => (
|
||||||
|
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty text="规则预警已生成,AI 解读会在触发条件满足时自动补充。" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="发版预测">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-[13px] leading-6 text-[var(--ink-soft)]">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<CalendarClock className="h-4 w-4 text-[var(--ink-muted)]" />
|
||||||
|
期望发版:{formatDateTime(risk.expectedReleaseDate)}
|
||||||
|
</p>
|
||||||
|
<p>预测可发:{formatDateTime(risk.forecastReleaseDate)}</p>
|
||||||
|
<p>预计延期:{risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 border-t border-[var(--line)] pt-3">{risk.trend.summary}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="风险证据">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="flex flex-wrap gap-1 border-b border-[var(--line)] bg-[var(--bg-subtle)] p-2">
|
||||||
|
<TabButton active={detailTab === 'reasons'} onClick={() => setDetailTab('reasons')}>
|
||||||
|
风险原因 {risk.reasons.length}
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={detailTab === 'silent'} onClick={() => setDetailTab('silent')}>
|
||||||
|
静默风险 {risk.silentRisks.length}
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={detailTab === 'evidence'} onClick={() => setDetailTab('evidence')}>
|
||||||
|
日报与活动证据 {evidenceItems.length}
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[220px] p-3">
|
||||||
|
{detailTab === 'reasons' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
|
||||||
|
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{reason.title}</p>
|
||||||
|
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{reason.severity}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'silent' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item, index) => (
|
||||||
|
<div key={`${item.key}-${item.itemId ?? index}`} className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<p className="text-[12px] font-medium text-amber-900">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-amber-800">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'evidence' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{item.summary}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">{formatDateTime(item.occurredAt)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[level]}`}>
|
||||||
|
{RISK_LEVEL_LABEL[level]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailMetric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) {
|
||||||
|
const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600';
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<p className="text-[11px] text-[var(--ink-muted)]">{label}</p>
|
||||||
|
<p className={`mt-1 text-[18px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectFilter({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
allLabel,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
allLabel: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-medium text-[var(--ink-muted)]">{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||||
|
>
|
||||||
|
<option value="">{allLabel}</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`h-8 rounded-md px-3 text-[12px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--bg-card)] text-[var(--accent)] shadow-sm'
|
||||||
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-card)] hover:text-[var(--ink)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||||
|
<div className="space-y-2">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProductOptions(risks: XiaobaoVersionRisk[]): FilterOption[] {
|
||||||
|
return uniqueOptions(risks.map((risk) => ({
|
||||||
|
id: risk.productId ?? '',
|
||||||
|
label: risk.productName ?? '未关联产品',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectOptions(risks: XiaobaoVersionRisk[], productId: string): FilterOption[] {
|
||||||
|
const scoped = productId ? risks.filter((risk) => risk.productId === productId) : risks;
|
||||||
|
return uniqueOptions(scoped.map((risk) => ({
|
||||||
|
id: risk.projectId ?? '',
|
||||||
|
label: risk.projectName ?? '未关联项目',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueOptions(options: FilterOption[]): FilterOption[] {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
options.forEach((option) => {
|
||||||
|
if (!option.id || map.has(option.id)) return;
|
||||||
|
map.set(option.id, option.label);
|
||||||
|
});
|
||||||
|
return Array.from(map, ([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
|
type NavItemConfig = {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: any;
|
||||||
|
permission: string | null;
|
||||||
|
badge?: 'xiaobao-risk';
|
||||||
|
};
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
label: '工作区',
|
label: '工作区',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
||||||
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
||||||
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
||||||
@@ -103,7 +114,7 @@ function UserBlock() {
|
|||||||
|
|
||||||
function NavGroup({ label, items, isActive, onNavigate }: {
|
function NavGroup({ label, items, isActive, onNavigate }: {
|
||||||
label: string;
|
label: string;
|
||||||
items: { label: string; path: string; icon: any; permission: string | null }[];
|
items: NavItemConfig[];
|
||||||
isActive: (p: string) => boolean;
|
isActive: (p: string) => boolean;
|
||||||
onNavigate: (p: string) => void;
|
onNavigate: (p: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -120,7 +131,7 @@ function NavGroup({ label, items, isActive, onNavigate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ item, active, onNavigate }: {
|
function NavItem({ item, active, onNavigate }: {
|
||||||
item: { label: string; path: string; icon: any; permission: string | null };
|
item: NavItemConfig;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onNavigate: (p: string) => void;
|
onNavigate: (p: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -137,7 +148,20 @@ function NavItem({ item, active, onNavigate }: {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
{item.badge === 'xiaobao-risk' && <XiaobaoRiskNavBadge />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function XiaobaoRiskNavBadge() {
|
||||||
|
const { risks } = useXiaobaoWarningRisks();
|
||||||
|
const count = getXiaobaoWarningRiskCount(risks);
|
||||||
|
if (count <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] font-semibold leading-none text-white">
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
77
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx
Normal file
77
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronRight, TriangleAlert } from 'lucide-react';
|
||||||
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
|
|
||||||
|
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
on_track: '按期',
|
||||||
|
attention: '关注',
|
||||||
|
at_risk: '有风险',
|
||||||
|
likely_delayed: '大概率延期',
|
||||||
|
blocked: '阻塞',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
at_risk: 'border-orange-200 bg-orange-50 text-orange-700',
|
||||||
|
likely_delayed: 'border-red-200 bg-red-50 text-red-700',
|
||||||
|
blocked: 'border-zinc-900 bg-zinc-900 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function XiaobaoWarningCard({
|
||||||
|
risk,
|
||||||
|
active = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
risk: XiaobaoVersionRisk;
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--line)] bg-[var(--bg)] hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--bg-subtle)] text-[var(--accent)]">
|
||||||
|
<TriangleAlert className="h-4 w-4" strokeWidth={1.8} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-[13px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
|
||||||
|
{RISK_LEVEL_LABEL[risk.riskLevel]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
|
||||||
|
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="mt-2 h-4 w-4 shrink-0 text-[var(--ink-muted)]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) {
|
||||||
|
const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600';
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-2">
|
||||||
|
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
|
||||||
|
<p className={`mt-0.5 text-[13px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
Normal file
154
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CalendarClock, Sparkles, X } from 'lucide-react';
|
||||||
|
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
|
export function XiaobaoWarningDrawer({
|
||||||
|
risk,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
risk: XiaobaoVersionRisk;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
const evidence = risk.dailyEvidence;
|
||||||
|
const evidenceItems = [
|
||||||
|
...(evidence?.todayDeliveries ?? []),
|
||||||
|
...(evidence?.todayProgress ?? []),
|
||||||
|
...(evidence?.todayRisks ?? []),
|
||||||
|
...(evidence?.progressNotes ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||||
|
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)] shadow-2xl">
|
||||||
|
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-2 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName}
|
||||||
|
</div>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="truncate text-[15px] font-semibold text-[var(--ink)]">小宝预警详情</h2>
|
||||||
|
<p className="text-[11px] text-[var(--ink-muted)]">规则预警会保留事实证据,AI 解读自动补充</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||||
|
<Summary label="风险分" value={`${risk.riskScore}`} />
|
||||||
|
<Summary label="置信度" value={`${risk.confidence}%`} />
|
||||||
|
<Summary label="剩余工作量" value={formatRemainingWork(risk.remainingWorkHours)} />
|
||||||
|
<Summary label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="发版预测">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<CalendarClock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
期望发版: {formatDateTime(risk.expectedReleaseDate)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">预测可发: {formatDateTime(risk.forecastReleaseDate)}</p>
|
||||||
|
<p className="mt-1">{risk.trend.summary}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="风险原因">
|
||||||
|
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
|
||||||
|
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{reason.title}</p>
|
||||||
|
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{reason.severity}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="日报与活动证据">
|
||||||
|
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{item.summary}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">{formatDateTime(item.occurredAt)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="静默风险">
|
||||||
|
{risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item, index) => (
|
||||||
|
<div key={`${item.key}-${item.itemId ?? index}`} className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<p className="text-[12px] font-medium text-amber-900">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-amber-800">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="小宝建议">
|
||||||
|
{risk.aiInsight ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<p className="flex items-start gap-1.5 text-[12px] font-medium text-[var(--ink)]">
|
||||||
|
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
|
||||||
|
<span>{risk.aiInsight.summary}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
|
||||||
|
</div>
|
||||||
|
{risk.aiInsight.suggestedActions.map((action) => (
|
||||||
|
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty text="规则预警已生成,AI 解读会在触发条件满足时自动补充" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="mt-1 flex h-9 w-full items-center justify-center rounded-lg bg-[var(--accent)] text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
||||||
|
>
|
||||||
|
打开版本详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<p className="text-[11px] text-[var(--ink-muted)]">{label}</p>
|
||||||
|
<p className="mt-1 text-[18px] font-semibold tabular-nums text-[var(--ink)]">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="mb-5">
|
||||||
|
<h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||||
|
<div className="space-y-2">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/web/hooks/useXiaobaoWarningRisks.ts
Normal file
119
apps/web/hooks/useXiaobaoWarningRisks.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
|
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||||
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
|
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
|
import { flattenVersions } from '@/lib/derive';
|
||||||
|
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
|
export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCache?: boolean } = {}) {
|
||||||
|
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { overview, fetchOverview } = useProductStore();
|
||||||
|
const { plans, fetchPlans } = useVersionPlanStore();
|
||||||
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
|
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||||
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
|
const { activities, fetchActivities } = useWorkActivityStore();
|
||||||
|
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||||
|
const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
|
||||||
|
const [calculationNow] = useState(() => new Date());
|
||||||
|
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
|
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||||
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
|
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||||
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
|
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
||||||
|
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadRiskCache) fetchRiskData();
|
||||||
|
}, [fetchRiskData, loadRiskCache]);
|
||||||
|
|
||||||
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
const visibleVersions = useMemo(
|
||||||
|
() => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }),
|
||||||
|
[allVersions, canManage, user?.name],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requirementVersionMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
requirements.forEach((requirement) => {
|
||||||
|
if (requirement.versionId) map.set(requirement.id, requirement.versionId);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [requirements]);
|
||||||
|
|
||||||
|
const workItems = useMemo(() => buildXiaobaoWorkItems({
|
||||||
|
plans,
|
||||||
|
devTasks,
|
||||||
|
testCases,
|
||||||
|
bugs,
|
||||||
|
versions: allVersions.map((version) => ({
|
||||||
|
id: version.id,
|
||||||
|
name: version.name,
|
||||||
|
productName: version.productName,
|
||||||
|
projectName: version.projectName,
|
||||||
|
})),
|
||||||
|
requirementVersionMap,
|
||||||
|
}), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]);
|
||||||
|
|
||||||
|
const risks = useMemo(() => visibleVersions.map((version) => {
|
||||||
|
const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id);
|
||||||
|
const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id));
|
||||||
|
const dailyEvidence = buildVersionDailyEvidence({
|
||||||
|
versionId: version.id,
|
||||||
|
workItems,
|
||||||
|
activities,
|
||||||
|
worklogs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return calcXiaobaoVersionRisk({
|
||||||
|
version,
|
||||||
|
devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)),
|
||||||
|
testCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
||||||
|
bugs: bugs.filter((bug) => bug.versionId === version.id),
|
||||||
|
dailyEvidence,
|
||||||
|
recentActivityCount: dailyEvidence.recentActivityCount,
|
||||||
|
lastActivityAt: dailyEvidence.lastActivityAt,
|
||||||
|
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
|
||||||
|
now: calculationNow,
|
||||||
|
});
|
||||||
|
}).sort((a, b) => b.riskScore - a.riskScore), [
|
||||||
|
activities,
|
||||||
|
bugs,
|
||||||
|
devTasks,
|
||||||
|
requirements,
|
||||||
|
testCases,
|
||||||
|
visibleVersions,
|
||||||
|
workItems,
|
||||||
|
worklogs,
|
||||||
|
snapshots,
|
||||||
|
today,
|
||||||
|
calculationNow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
risks,
|
||||||
|
snapshots,
|
||||||
|
insights,
|
||||||
|
riskDataLoaded,
|
||||||
|
saveSnapshot,
|
||||||
|
saveInsight,
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
}
|
||||||
389
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
389
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
import {
|
||||||
|
buildRiskInsightSignature,
|
||||||
|
buildRiskInterpretRequest,
|
||||||
|
findLatestRiskInsightForVersion,
|
||||||
|
findPreviousRiskSnapshot,
|
||||||
|
getReusableInsight,
|
||||||
|
shouldRequestRiskInsight,
|
||||||
|
shouldRequestRiskInsightWithCacheGate,
|
||||||
|
shouldRequestRiskInsightWithCooldown,
|
||||||
|
} from './xiaobao-risk-ai';
|
||||||
|
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
|
|
||||||
|
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
date: '2026-06-28',
|
||||||
|
riskScore: 35,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
|
||||||
|
openBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 80,
|
||||||
|
createdAt: '2026-06-28T10:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
productName: 'FTB',
|
||||||
|
projectName: 'PM',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
expectedReleaseDate: '2026-07-01T10:00:00.000Z',
|
||||||
|
confidence: 80,
|
||||||
|
confidenceLevel: 'high',
|
||||||
|
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
|
||||||
|
delayDays: 1,
|
||||||
|
remainingWorkHours: 12,
|
||||||
|
reasons: [
|
||||||
|
{ key: 'failed_test', title: 'Failed tests', detail: '1 test failed.', severity: 'warning', count: 1 },
|
||||||
|
{ key: 'critical_bug', title: 'Critical bug', detail: 'P1 bug exists.', severity: 'danger', count: 1 },
|
||||||
|
],
|
||||||
|
silentRisks: [{ key: 'no_update', title: 'No update', detail: 'No update for 5 days.' }],
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }],
|
||||||
|
todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }],
|
||||||
|
todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }],
|
||||||
|
progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }],
|
||||||
|
needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }],
|
||||||
|
recentActivityCount: 3,
|
||||||
|
lastActivityAt: '2026-06-29T13:00:00.000Z',
|
||||||
|
},
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 4,
|
||||||
|
openBugCount: 3,
|
||||||
|
criticalBugCount: 1,
|
||||||
|
failedTestCount: 1,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 1,
|
||||||
|
daysToExpectedRelease: 1,
|
||||||
|
},
|
||||||
|
currentSnapshot: snapshot({ riskScore: 68, openBugCount: 3, failedTestCount: 1, silentRiskCount: 1 }),
|
||||||
|
trend: { direction: 'up', delta: 33, summary: 'Risk rose by 33 points.', pattern: 'score_delta' },
|
||||||
|
...patch,
|
||||||
|
} as unknown as XiaobaoVersionRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insight(patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
|
||||||
|
insight: {
|
||||||
|
summary: 'Risk needs attention.',
|
||||||
|
why: ['Risk rose.'],
|
||||||
|
forecast: 'May slip.',
|
||||||
|
suggestedActions: ['Confirm scope.'],
|
||||||
|
ownerHints: ['PM'],
|
||||||
|
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight skips on_track', () => {
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight does not trigger ordinary attention without a previous worsening signal', () => {
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 42, signals: { ...risk().signals, unfinishedCount: 0, daysToExpectedRelease: 5 } }), undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 68 }), snapshot({ riskScore: 35 })), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsight(
|
||||||
|
risk({
|
||||||
|
riskLevel: 'attention',
|
||||||
|
riskScore: 52,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 4,
|
||||||
|
openBugCount: 3,
|
||||||
|
criticalBugCount: 3,
|
||||||
|
failedTestCount: 1,
|
||||||
|
blockedCount: 1,
|
||||||
|
silentRiskCount: 1,
|
||||||
|
daysToExpectedRelease: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
snapshot({ riskScore: 50, openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 }),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention when critical bugs increase even if open bug total is unchanged', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsight(
|
||||||
|
risk({
|
||||||
|
riskLevel: 'attention',
|
||||||
|
riskScore: 45,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 0,
|
||||||
|
openBugCount: 2,
|
||||||
|
criticalBugCount: 1,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
snapshot({ riskScore: 44, openBugCount: 2, criticalBugCount: 0 } as Partial<XiaobaoRiskSnapshot>),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention on continuously rising trend', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsight(
|
||||||
|
risk({
|
||||||
|
riskLevel: 'attention',
|
||||||
|
riskScore: 46,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 0,
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 5,
|
||||||
|
},
|
||||||
|
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||||
|
}),
|
||||||
|
snapshot({ riskScore: 38 }),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention on continuously rising trend without previous snapshot', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsight(
|
||||||
|
risk({
|
||||||
|
riskLevel: 'attention',
|
||||||
|
riskScore: 46,
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 0,
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 5,
|
||||||
|
},
|
||||||
|
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers attention near release with unfinished work even without history', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsight(risk({ riskLevel: 'attention', signals: { ...risk().signals, unfinishedCount: 2, daysToExpectedRelease: 1 } }), undefined),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsight triggers high risk levels', () => {
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'at_risk' }), undefined), true);
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'likely_delayed' }), undefined), true);
|
||||||
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), undefined), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findLatestRiskInsightForVersion returns latest generated insight for a version', () => {
|
||||||
|
const latest = findLatestRiskInsightForVersion([
|
||||||
|
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T08:00:00.000Z' }),
|
||||||
|
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T10:00:00.000Z' }),
|
||||||
|
insight({ versionId: 'ver-2', generatedAt: '2026-06-29T11:00:00.000Z' }),
|
||||||
|
], 'ver-1');
|
||||||
|
|
||||||
|
assert.equal(latest?.generatedAt, '2026-06-29T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCooldown skips repeated AI requests inside cooldown', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
|
||||||
|
const previous = snapshot({ riskScore: 40 });
|
||||||
|
const latest = insight({
|
||||||
|
generatedAt: '2026-06-29T10:00:00.000Z',
|
||||||
|
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 60 })),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCooldown allows AI requests after cooldown', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
|
||||||
|
const previous = snapshot({ riskScore: 40 });
|
||||||
|
const latest = insight({ generatedAt: '2026-06-29T04:00:00.000Z' });
|
||||||
|
|
||||||
|
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level escalates', () => {
|
||||||
|
const previousInsight = insight({
|
||||||
|
generatedAt: '2026-06-29T10:30:00.000Z',
|
||||||
|
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
|
||||||
|
});
|
||||||
|
const current = risk({ riskLevel: 'blocked', riskScore: 90 });
|
||||||
|
|
||||||
|
assert.equal(shouldRequestRiskInsightWithCooldown(current, snapshot({ riskScore: 50 }), previousInsight, new Date('2026-06-29T11:00:00.000Z')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCacheGate waits for cache loading before AI requests', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsightWithCacheGate(false, [], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCacheGate reuses unchanged cached insight after cache loading', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||||
|
const cached = insight({
|
||||||
|
riskSignature: buildRiskInsightSignature(current),
|
||||||
|
generatedAt: '2026-06-29T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cache loading and cooldown', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||||
|
const previousInsight = insight({
|
||||||
|
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 62 })),
|
||||||
|
generatedAt: '2026-06-29T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsightWithCacheGate(true, [previousInsight], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
||||||
|
const base = buildRiskInsightSignature(risk({
|
||||||
|
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
||||||
|
currentSnapshot: snapshot({ openBugCount: 1, riskScore: 45 }),
|
||||||
|
}));
|
||||||
|
const changed = buildRiskInsightSignature(risk({
|
||||||
|
signals: { ...risk().signals, openBugCount: 2, criticalBugCount: 0 },
|
||||||
|
currentSnapshot: snapshot({ openBugCount: 2, riskScore: 45 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.notEqual(base, changed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRiskInsightSignature changes when trend or evidence changes', () => {
|
||||||
|
const base = buildRiskInsightSignature(risk({
|
||||||
|
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
|
||||||
|
}));
|
||||||
|
const changed = buildRiskInsightSignature(risk({
|
||||||
|
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||||
|
dailyEvidence: {
|
||||||
|
...risk().dailyEvidence!,
|
||||||
|
todayRisks: [{ id: 'ev-new', title: 'Risk', summary: 'New P1 regression appeared.', occurredAt: '2026-06-29T15:00:00.000Z' }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.notEqual(base, changed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRiskInsightSignature changes when trend direction delta or activity metadata changes', () => {
|
||||||
|
const base = buildRiskInsightSignature(risk({
|
||||||
|
trend: { direction: 'up', delta: 10, summary: 'Risk changed.', pattern: 'score_delta' },
|
||||||
|
dailyEvidence: {
|
||||||
|
...risk().dailyEvidence!,
|
||||||
|
recentActivityCount: 1,
|
||||||
|
lastActivityAt: '2026-06-29T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const changed = buildRiskInsightSignature(risk({
|
||||||
|
trend: { direction: 'down', delta: -10, summary: 'Risk changed.', pattern: 'score_delta' },
|
||||||
|
dailyEvidence: {
|
||||||
|
...risk().dailyEvidence!,
|
||||||
|
recentActivityCount: 2,
|
||||||
|
lastActivityAt: '2026-06-29T11:00:00.000Z',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.notEqual(base, changed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRiskInsightSignature stays stable when only volatile same-day forecast timing changes', () => {
|
||||||
|
const base = buildRiskInsightSignature(risk({
|
||||||
|
forecastReleaseDate: '2026-07-14T03:06:58.211Z',
|
||||||
|
signals: { ...risk().signals, daysToExpectedRelease: 30.9 },
|
||||||
|
}));
|
||||||
|
const changed = buildRiskInsightSignature(risk({
|
||||||
|
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
|
||||||
|
signals: { ...risk().signals, daysToExpectedRelease: 30.1 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(base, changed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getReusableInsight reuses legacy signatures with volatile forecast timestamps', () => {
|
||||||
|
const current = risk({
|
||||||
|
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
|
||||||
|
signals: { ...risk().signals, daysToExpectedRelease: 30.1 },
|
||||||
|
});
|
||||||
|
const legacy = insight({
|
||||||
|
riskSignature: JSON.stringify({
|
||||||
|
...JSON.parse(buildRiskInsightSignature(current)),
|
||||||
|
forecastReleaseDate: '2026-07-14T03:06:58.211Z',
|
||||||
|
signals: {
|
||||||
|
...current.signals,
|
||||||
|
daysToExpectedRelease: 30.9,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getReusableInsight([legacy], current)?.insight.summary, legacy.insight.summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => {
|
||||||
|
const payload = buildRiskInterpretRequest(risk());
|
||||||
|
|
||||||
|
assert.equal(payload.versionName, 'V1.0');
|
||||||
|
assert.equal(payload.reasons[0].label, 'Failed tests');
|
||||||
|
assert.equal(payload.reasons[0].severity, 'medium');
|
||||||
|
assert.equal(payload.reasons[1].severity, 'critical');
|
||||||
|
assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']);
|
||||||
|
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
||||||
|
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findPreviousRiskSnapshot returns latest snapshot before today for the version', () => {
|
||||||
|
const previous = findPreviousRiskSnapshot(
|
||||||
|
[
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 70, createdAt: '2026-06-29T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, createdAt: '2026-06-28T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, createdAt: '2026-06-27T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-2', date: '2026-06-28', riskScore: 90, createdAt: '2026-06-28T11:00:00.000Z' }),
|
||||||
|
],
|
||||||
|
'ver-1',
|
||||||
|
'2026-06-29',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(previous?.riskScore, 52);
|
||||||
|
});
|
||||||
331
apps/web/lib/xiaobao-risk-ai.ts
Normal file
331
apps/web/lib/xiaobao-risk-ai.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import type { AgentRiskInterpretError, AgentRiskInterpretRequest, AgentRiskInterpretResponse } from '@ftb/shared';
|
||||||
|
import { api } from './api';
|
||||||
|
import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import { findCachedInsight, type XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
|
||||||
|
type RiskInsightCurrent = Pick<
|
||||||
|
XiaobaoVersionRisk,
|
||||||
|
'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type RiskInsightPrevious = Pick<
|
||||||
|
XiaobaoRiskSnapshot,
|
||||||
|
| 'riskScore'
|
||||||
|
| 'confidence'
|
||||||
|
| 'forecastReleaseDate'
|
||||||
|
| 'openBugCount'
|
||||||
|
| 'criticalBugCount'
|
||||||
|
| 'failedTestCount'
|
||||||
|
| 'blockedCount'
|
||||||
|
| 'silentRiskCount'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const SCORE_TRIGGER_DELTA = 15;
|
||||||
|
const CONFIDENCE_DROP_DELTA = 15;
|
||||||
|
const ONE_DAY_MS = 86_400_000;
|
||||||
|
const RISK_INSIGHT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const RISK_LEVEL_RANK: Record<XiaobaoVersionRisk['riskLevel'], number> = {
|
||||||
|
on_track: 0,
|
||||||
|
attention: 1,
|
||||||
|
at_risk: 2,
|
||||||
|
likely_delayed: 3,
|
||||||
|
blocked: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
|
||||||
|
if (current.riskLevel === 'on_track') return false;
|
||||||
|
if (current.riskLevel === 'at_risk' || current.riskLevel === 'likely_delayed' || current.riskLevel === 'blocked') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseIsNearWithUnfinishedWork =
|
||||||
|
current.signals.daysToExpectedRelease !== undefined &&
|
||||||
|
current.signals.daysToExpectedRelease <= 1 &&
|
||||||
|
current.signals.unfinishedCount > 0;
|
||||||
|
if (!previous) return isWorseningTrend(current) || releaseIsNearWithUnfinishedWork;
|
||||||
|
|
||||||
|
const scoreDelta = current.riskScore - previous.riskScore;
|
||||||
|
const confidenceDrop = previous.confidence - current.confidence;
|
||||||
|
const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate
|
||||||
|
? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
scoreDelta >= SCORE_TRIGGER_DELTA ||
|
||||||
|
confidenceDrop >= CONFIDENCE_DROP_DELTA ||
|
||||||
|
forecastDelayMs >= ONE_DAY_MS ||
|
||||||
|
isWorseningTrend(current) ||
|
||||||
|
current.signals.openBugCount > previous.openBugCount ||
|
||||||
|
current.signals.criticalBugCount > (previous.criticalBugCount ?? 0) ||
|
||||||
|
current.signals.failedTestCount > previous.failedTestCount ||
|
||||||
|
current.signals.blockedCount > previous.blockedCount ||
|
||||||
|
current.signals.silentRiskCount > previous.silentRiskCount ||
|
||||||
|
releaseIsNearWithUnfinishedWork
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLatestRiskInsightForVersion(
|
||||||
|
cache: XiaobaoRiskInsightCacheItem[],
|
||||||
|
versionId: string,
|
||||||
|
): XiaobaoRiskInsightCacheItem | undefined {
|
||||||
|
return cache
|
||||||
|
.filter((item) => item.versionId === versionId)
|
||||||
|
.sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequestRiskInsightWithCooldown(
|
||||||
|
current: RiskInsightCurrent,
|
||||||
|
previous?: RiskInsightPrevious,
|
||||||
|
latestInsight?: XiaobaoRiskInsightCacheItem,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!shouldRequestRiskInsight(current, previous)) return false;
|
||||||
|
if (!latestInsight) return true;
|
||||||
|
if (isRiskLevelEscalation(current.riskLevel, latestInsight)) return true;
|
||||||
|
|
||||||
|
const generatedAt = getTime(latestInsight.generatedAt);
|
||||||
|
const nowTime = now.getTime();
|
||||||
|
if (!Number.isFinite(generatedAt) || !Number.isFinite(nowTime)) return true;
|
||||||
|
return nowTime - generatedAt >= RISK_INSIGHT_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequestRiskInsightWithCacheGate(
|
||||||
|
riskCacheLoaded: boolean,
|
||||||
|
cache: XiaobaoRiskInsightCacheItem[],
|
||||||
|
current: XiaobaoVersionRisk,
|
||||||
|
previous?: RiskInsightPrevious,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!riskCacheLoaded) return false;
|
||||||
|
if (getReusableInsight(cache, current)) return false;
|
||||||
|
const latestInsight = findLatestRiskInsightForVersion(cache, current.versionId);
|
||||||
|
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
versionId: risk.versionId,
|
||||||
|
riskScore: clampScore(risk.riskScore),
|
||||||
|
riskLevel: risk.riskLevel,
|
||||||
|
expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate),
|
||||||
|
forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate),
|
||||||
|
delayDays: risk.delayDays,
|
||||||
|
confidence: clampScore(risk.confidence),
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: risk.signals.unfinishedCount,
|
||||||
|
openBugCount: risk.signals.openBugCount,
|
||||||
|
criticalBugCount: risk.signals.criticalBugCount,
|
||||||
|
failedTestCount: risk.signals.failedTestCount,
|
||||||
|
blockedCount: risk.signals.blockedCount,
|
||||||
|
silentRiskCount: risk.signals.silentRiskCount,
|
||||||
|
daysToExpectedRelease: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease),
|
||||||
|
},
|
||||||
|
trend: {
|
||||||
|
direction: risk.trend.direction,
|
||||||
|
delta: risk.trend.delta,
|
||||||
|
summary: risk.trend.summary,
|
||||||
|
pattern: risk.trend.pattern ?? null,
|
||||||
|
},
|
||||||
|
reasons: normalizeReasons(risk.reasons),
|
||||||
|
silentRisks: normalizeSilentRisks(risk.silentRisks),
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||||
|
todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||||
|
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||||
|
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||||
|
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||||
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||||
|
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReusableInsight(
|
||||||
|
cache: XiaobaoRiskInsightCacheItem[],
|
||||||
|
risk: XiaobaoVersionRisk,
|
||||||
|
): XiaobaoRiskInsightCacheItem | undefined {
|
||||||
|
const currentSignature = buildRiskInsightSignature(risk);
|
||||||
|
return (
|
||||||
|
findCachedInsight(cache, risk.versionId, currentSignature) ??
|
||||||
|
cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPreviousRiskSnapshot(
|
||||||
|
snapshots: XiaobaoRiskSnapshot[],
|
||||||
|
versionId: string,
|
||||||
|
today: string,
|
||||||
|
): XiaobaoRiskSnapshot | undefined {
|
||||||
|
return snapshots
|
||||||
|
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date < today)
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest {
|
||||||
|
return {
|
||||||
|
versionId: risk.versionId,
|
||||||
|
versionName: risk.versionName,
|
||||||
|
productName: risk.productName,
|
||||||
|
projectName: risk.projectName,
|
||||||
|
riskScore: risk.riskScore,
|
||||||
|
riskLevel: risk.riskLevel,
|
||||||
|
expectedReleaseDate: risk.expectedReleaseDate,
|
||||||
|
forecastReleaseDate: risk.forecastReleaseDate,
|
||||||
|
delayDays: risk.delayDays,
|
||||||
|
confidence: risk.confidence,
|
||||||
|
signals: risk.signals,
|
||||||
|
trendSummary: risk.trend.summary,
|
||||||
|
reasons: risk.reasons.map(mapRiskReason),
|
||||||
|
silentRisks: risk.silentRisks.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
detail: item.detail,
|
||||||
|
})),
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||||
|
todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||||
|
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||||
|
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||||
|
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||||
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||||
|
lastActivityAt: risk.dailyEvidence?.lastActivityAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestRiskInsight(
|
||||||
|
risk: XiaobaoVersionRisk,
|
||||||
|
): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||||
|
return api.postRaw<AgentRiskInterpretResponse | AgentRiskInterpretError>(
|
||||||
|
'/ai/risk-interpret',
|
||||||
|
buildRiskInterpretRequest(risk),
|
||||||
|
120000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRiskReason(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number] {
|
||||||
|
return {
|
||||||
|
key: reason.key,
|
||||||
|
label: reason.title,
|
||||||
|
severity: mapReasonSeverity(reason),
|
||||||
|
detail: reason.detail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReasonSeverity(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number]['severity'] {
|
||||||
|
if (reason.severity === 'info') return 'low';
|
||||||
|
if (reason.severity === 'warning') return 'medium';
|
||||||
|
if (reason.key === 'critical_bug' || reason.key === 'blocked_work') return 'critical';
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeEvidence(items: Array<{ summary: string }> | undefined): string[] {
|
||||||
|
return (items ?? []).map((item) => item.summary).filter((summary) => summary.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorseningTrend(current: RiskInsightCurrent): boolean {
|
||||||
|
return current.trend.direction === 'up' && current.trend.pattern === 'continuous_rising';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasons(reasons: XiaobaoVersionRisk['reasons']) {
|
||||||
|
return reasons
|
||||||
|
.map((reason) => ({
|
||||||
|
key: reason.key,
|
||||||
|
title: reason.title,
|
||||||
|
severity: reason.severity,
|
||||||
|
detail: reason.detail,
|
||||||
|
count: reason.count ?? null,
|
||||||
|
}))
|
||||||
|
.sort(compareSignatureRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSilentRisks(silentRisks: XiaobaoVersionRisk['silentRisks']) {
|
||||||
|
return silentRisks
|
||||||
|
.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
title: item.title,
|
||||||
|
detail: item.detail,
|
||||||
|
itemId: item.itemId ?? null,
|
||||||
|
itemType: item.itemType ?? null,
|
||||||
|
}))
|
||||||
|
.sort(compareSignatureRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEvidence(items: Array<{ id?: string; title?: string; summary: string; occurredAt?: string }> | undefined) {
|
||||||
|
return (items ?? [])
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id ?? null,
|
||||||
|
title: item.title ?? null,
|
||||||
|
summary: item.summary,
|
||||||
|
occurredAt: item.occurredAt ?? null,
|
||||||
|
}))
|
||||||
|
.sort(compareSignatureRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSignatureRows<T>(a: T, b: T): number {
|
||||||
|
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScore(score: number): number {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(score)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRiskLevelEscalation(
|
||||||
|
currentLevel: XiaobaoVersionRisk['riskLevel'],
|
||||||
|
latestInsight: XiaobaoRiskInsightCacheItem,
|
||||||
|
): boolean {
|
||||||
|
const previousLevel = parseRiskLevel(latestInsight.riskSignature);
|
||||||
|
if (!previousLevel) return false;
|
||||||
|
return RISK_LEVEL_RANK[currentLevel] > RISK_LEVEL_RANK[previousLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoVersionRisk['riskLevel'] };
|
||||||
|
return parsed.riskLevel && parsed.riskLevel in RISK_LEVEL_RANK ? parsed.riskLevel : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCachedRiskSignature(signature: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(signature) as {
|
||||||
|
expectedReleaseDate?: string | null;
|
||||||
|
forecastReleaseDate?: string | null;
|
||||||
|
signals?: { daysToExpectedRelease?: number | null };
|
||||||
|
};
|
||||||
|
return JSON.stringify({
|
||||||
|
...parsed,
|
||||||
|
expectedReleaseDate: normalizeDateKey(parsed.expectedReleaseDate),
|
||||||
|
forecastReleaseDate: normalizeDateKey(parsed.forecastReleaseDate),
|
||||||
|
signals: {
|
||||||
|
...parsed.signals,
|
||||||
|
daysToExpectedRelease: normalizeDaysToExpectedRelease(parsed.signals?.daysToExpectedRelease),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateKey(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = value.trim();
|
||||||
|
if (raw.length === 0) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
|
||||||
|
|
||||||
|
const time = new Date(raw).getTime();
|
||||||
|
if (!Number.isFinite(time)) return raw;
|
||||||
|
return new Date(time).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDaysToExpectedRelease(value: number | null | undefined): number | null {
|
||||||
|
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
||||||
|
return value >= 0 ? Math.ceil(value) : Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTime(value: string): number {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : Number.NaN;
|
||||||
|
}
|
||||||
103
apps/web/lib/xiaobao-risk-cache.test.ts
Normal file
103
apps/web/lib/xiaobao-risk-cache.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
import {
|
||||||
|
findCachedInsight,
|
||||||
|
mergeDailySnapshotCacheForSave,
|
||||||
|
mergeInsightCacheForSave,
|
||||||
|
upsertDailySnapshot,
|
||||||
|
upsertInsight,
|
||||||
|
type XiaobaoRiskInsightCacheItem,
|
||||||
|
} from './xiaobao-risk-cache';
|
||||||
|
|
||||||
|
const insightA: XiaobaoRiskInsightCacheItem = {
|
||||||
|
versionId: 'v-1',
|
||||||
|
riskSignature: 'sig-a',
|
||||||
|
insight: {
|
||||||
|
summary: '版本风险上升',
|
||||||
|
why: ['剩余工作较多'],
|
||||||
|
forecast: '可能延期 2 天',
|
||||||
|
suggestedActions: ['压缩低优先级范围'],
|
||||||
|
ownerHints: ['请项目负责人确认排期'],
|
||||||
|
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('findCachedInsight returns matching signature only', () => {
|
||||||
|
const rows: XiaobaoRiskInsightCacheItem[] = [
|
||||||
|
insightA,
|
||||||
|
{ ...insightA, versionId: 'v-2', riskSignature: 'sig-a' },
|
||||||
|
{ ...insightA, versionId: 'v-1', riskSignature: 'sig-b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(findCachedInsight(rows, 'v-1', 'sig-a'), insightA);
|
||||||
|
assert.equal(findCachedInsight(rows, 'v-1', 'sig-b')?.versionId, 'v-1');
|
||||||
|
assert.equal(findCachedInsight(rows, 'v-2', 'sig-b'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsertDailySnapshot keeps one snapshot per version and date', () => {
|
||||||
|
const existing: XiaobaoRiskSnapshot = {
|
||||||
|
versionId: 'v-1',
|
||||||
|
date: '2026-06-29',
|
||||||
|
riskScore: 40,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
openBugCount: 1,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 80,
|
||||||
|
createdAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
};
|
||||||
|
const replacement: XiaobaoRiskSnapshot = { ...existing, riskScore: 72, createdAt: '2026-06-29T09:00:00.000Z' };
|
||||||
|
const otherDay: XiaobaoRiskSnapshot = { ...existing, date: '2026-06-28', createdAt: '2026-06-28T09:00:00.000Z' };
|
||||||
|
|
||||||
|
const result = upsertDailySnapshot([existing, otherDay], replacement);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [replacement, otherDay]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsertInsight replaces existing version signature pair', () => {
|
||||||
|
const replacement: XiaobaoRiskInsightCacheItem = {
|
||||||
|
...insightA,
|
||||||
|
insight: { ...insightA.insight, summary: '已重新生成' },
|
||||||
|
generatedAt: '2026-06-29T09:00:00.000Z',
|
||||||
|
};
|
||||||
|
const otherSignature: XiaobaoRiskInsightCacheItem = { ...insightA, riskSignature: 'sig-b' };
|
||||||
|
const otherVersion: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'v-2' };
|
||||||
|
|
||||||
|
const result = upsertInsight([insightA, otherSignature, otherVersion], replacement);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [replacement, otherSignature, otherVersion]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeDailySnapshotCacheForSave preserves remote rows and local-only rows', () => {
|
||||||
|
const remoteOnly: XiaobaoRiskSnapshot = {
|
||||||
|
versionId: 'remote-version',
|
||||||
|
date: '2026-06-29',
|
||||||
|
riskScore: 30,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
openBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 90,
|
||||||
|
createdAt: '2026-06-29T08:00:00.000Z',
|
||||||
|
};
|
||||||
|
const localOnly: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'local-version', riskScore: 50 };
|
||||||
|
const item: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'current-version', riskScore: 70 };
|
||||||
|
|
||||||
|
const result = mergeDailySnapshotCacheForSave([localOnly], [remoteOnly], item);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [item, remoteOnly, localOnly]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeInsightCacheForSave preserves remote rows and local-only rows', () => {
|
||||||
|
const remoteOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'remote-version' };
|
||||||
|
const localOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'local-version' };
|
||||||
|
const item: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'current-version' };
|
||||||
|
|
||||||
|
const result = mergeInsightCacheForSave([localOnly], [remoteOnly], item);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [item, remoteOnly, localOnly]);
|
||||||
|
});
|
||||||
67
apps/web/lib/xiaobao-risk-cache.ts
Normal file
67
apps/web/lib/xiaobao-risk-cache.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
|
||||||
|
export interface XiaobaoRiskInsight {
|
||||||
|
summary: string;
|
||||||
|
why: string[];
|
||||||
|
forecast: string;
|
||||||
|
recommendedReleaseWindow?: string;
|
||||||
|
suggestedActions: string[];
|
||||||
|
ownerHints: string[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XiaobaoRiskInsightCacheItem {
|
||||||
|
versionId: string;
|
||||||
|
riskSignature: string;
|
||||||
|
insight: XiaobaoRiskInsight;
|
||||||
|
generatedAt: string;
|
||||||
|
providerInfo?: { providerId?: string; model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCachedInsight(
|
||||||
|
rows: XiaobaoRiskInsightCacheItem[],
|
||||||
|
versionId: string,
|
||||||
|
riskSignature: string,
|
||||||
|
): XiaobaoRiskInsightCacheItem | undefined {
|
||||||
|
return rows.find((row) => row.versionId === versionId && row.riskSignature === riskSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertInsight(
|
||||||
|
rows: XiaobaoRiskInsightCacheItem[],
|
||||||
|
item: XiaobaoRiskInsightCacheItem,
|
||||||
|
): XiaobaoRiskInsightCacheItem[] {
|
||||||
|
return [
|
||||||
|
item,
|
||||||
|
...rows.filter((row) => row.versionId !== item.versionId || row.riskSignature !== item.riskSignature),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertDailySnapshot(
|
||||||
|
rows: XiaobaoRiskSnapshot[],
|
||||||
|
item: XiaobaoRiskSnapshot,
|
||||||
|
): XiaobaoRiskSnapshot[] {
|
||||||
|
return [
|
||||||
|
item,
|
||||||
|
...rows.filter((row) => row.versionId !== item.versionId || row.date !== item.date),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDailySnapshotCacheForSave(
|
||||||
|
localRows: XiaobaoRiskSnapshot[],
|
||||||
|
remoteRows: XiaobaoRiskSnapshot[],
|
||||||
|
item: XiaobaoRiskSnapshot,
|
||||||
|
): XiaobaoRiskSnapshot[] {
|
||||||
|
const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.date}`));
|
||||||
|
const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.date}`));
|
||||||
|
return upsertDailySnapshot([...remoteRows, ...localOnly], item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeInsightCacheForSave(
|
||||||
|
localRows: XiaobaoRiskInsightCacheItem[],
|
||||||
|
remoteRows: XiaobaoRiskInsightCacheItem[],
|
||||||
|
item: XiaobaoRiskInsightCacheItem,
|
||||||
|
): XiaobaoRiskInsightCacheItem[] {
|
||||||
|
const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.riskSignature}`));
|
||||||
|
const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.riskSignature}`));
|
||||||
|
return upsertInsight([...remoteRows, ...localOnly], item);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import type { Bug } from './bug';
|
import type { Bug } from './bug';
|
||||||
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
|
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot, summarizeRiskTrend } from './xiaobao-risk-trend';
|
||||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
|
||||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
||||||
@@ -41,6 +41,45 @@ test('buildRiskSignature changes when score and bug counts change', () => {
|
|||||||
assert.notEqual(base, changed);
|
assert.notEqual(base, changed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildRiskSignature changes when critical bug count changes without open bug count change', () => {
|
||||||
|
const base = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 0 }));
|
||||||
|
const changed = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 1 }));
|
||||||
|
|
||||||
|
assert.notEqual(base, changed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => {
|
||||||
|
const latest = findLatestDailySnapshot([
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 45, createdAt: '2026-06-29T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 70, createdAt: '2026-06-28T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-2', date: '2026-06-29', riskScore: 90, createdAt: '2026-06-29T11:00:00.000Z' }),
|
||||||
|
], 'ver-1', '2026-06-29');
|
||||||
|
|
||||||
|
assert.equal(latest?.riskScore, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSaveRiskSnapshot skips small same-day changes inside throttle window', () => {
|
||||||
|
const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||||
|
const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:03:00.000Z' });
|
||||||
|
|
||||||
|
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:03:00.000Z')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSaveRiskSnapshot saves material signal changes immediately', () => {
|
||||||
|
const previous = snapshot({ date: '2026-06-29', riskScore: 40, criticalBugCount: 0, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||||
|
const current = snapshot({ date: '2026-06-29', riskScore: 41, criticalBugCount: 1, createdAt: '2026-06-29T10:02:00.000Z' });
|
||||||
|
|
||||||
|
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:02:00.000Z')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSaveRiskSnapshot saves minor signature changes after throttle window', () => {
|
||||||
|
const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||||
|
const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:12:00.000Z' });
|
||||||
|
|
||||||
|
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:12:00.000Z')), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
|
test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
|
||||||
const now = new Date('2026-07-02T01:00:00.000Z');
|
const now = new Date('2026-07-02T01:00:00.000Z');
|
||||||
const risk = calcXiaobaoVersionRisk({
|
const risk = calcXiaobaoVersionRisk({
|
||||||
@@ -59,19 +98,10 @@ test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot si
|
|||||||
|
|
||||||
assert.equal(risk.signals.openBugCount, 1);
|
assert.equal(risk.signals.openBugCount, 1);
|
||||||
assert.equal(risk.signals.criticalBugCount, 0);
|
assert.equal(risk.signals.criticalBugCount, 0);
|
||||||
assert.equal(currentSignature, buildRiskSignature({
|
assert.equal(risk.currentSnapshot.openBugCount, 1);
|
||||||
versionId: risk.versionId,
|
assert.equal(risk.currentSnapshot.criticalBugCount, 0);
|
||||||
date: now.toISOString().slice(0, 10),
|
assert.equal(currentSignature, buildRiskSignature(risk.currentSnapshot));
|
||||||
riskScore: risk.riskScore,
|
assert.equal(buildRiskSignature(risk.currentSnapshot).split('|')[5], '1');
|
||||||
riskLevel: risk.riskLevel,
|
|
||||||
forecastReleaseDate: risk.forecastReleaseDate,
|
|
||||||
openBugCount: 1,
|
|
||||||
failedTestCount: 0,
|
|
||||||
blockedCount: 0,
|
|
||||||
silentRiskCount: 0,
|
|
||||||
confidence: risk.confidence,
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function bug(patch: Partial<Bug> = {}): Bug {
|
function bug(patch: Partial<Bug> = {}): Bug {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot {
|
|||||||
riskLevel: XiaobaoRiskLevel;
|
riskLevel: XiaobaoRiskLevel;
|
||||||
forecastReleaseDate?: string;
|
forecastReleaseDate?: string;
|
||||||
openBugCount: number;
|
openBugCount: number;
|
||||||
|
criticalBugCount?: number;
|
||||||
failedTestCount: number;
|
failedTestCount: number;
|
||||||
blockedCount: number;
|
blockedCount: number;
|
||||||
silentRiskCount: number;
|
silentRiskCount: number;
|
||||||
@@ -22,6 +23,10 @@ export interface RiskTrendSummary {
|
|||||||
currentSignature?: string;
|
currentSignature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT_SAVE_THROTTLE_MS = 10 * 60 * 1000;
|
||||||
|
const SNAPSHOT_SCORE_DELTA = 5;
|
||||||
|
const ONE_DAY_MS = 86_400_000;
|
||||||
|
|
||||||
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
|
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
|
||||||
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
||||||
if (sorted.length < 2) {
|
if (sorted.length < 2) {
|
||||||
@@ -80,6 +85,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
|||||||
snapshot.riskLevel,
|
snapshot.riskLevel,
|
||||||
snapshot.forecastReleaseDate ?? '',
|
snapshot.forecastReleaseDate ?? '',
|
||||||
snapshot.openBugCount,
|
snapshot.openBugCount,
|
||||||
|
snapshot.criticalBugCount ?? 0,
|
||||||
snapshot.failedTestCount,
|
snapshot.failedTestCount,
|
||||||
snapshot.blockedCount,
|
snapshot.blockedCount,
|
||||||
snapshot.silentRiskCount,
|
snapshot.silentRiskCount,
|
||||||
@@ -87,6 +93,49 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
|||||||
].join('|');
|
].join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findLatestDailySnapshot(
|
||||||
|
snapshots: XiaobaoRiskSnapshot[],
|
||||||
|
versionId: string,
|
||||||
|
date: string,
|
||||||
|
): XiaobaoRiskSnapshot | undefined {
|
||||||
|
return snapshots
|
||||||
|
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date === date)
|
||||||
|
.sort((a, b) => getSnapshotTime(b) - getSnapshotTime(a))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSaveRiskSnapshot(
|
||||||
|
current: XiaobaoRiskSnapshot,
|
||||||
|
previous?: XiaobaoRiskSnapshot,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!previous) return true;
|
||||||
|
if (previous.versionId !== current.versionId || previous.date !== current.date) return true;
|
||||||
|
if (buildRiskSignature(current) === buildRiskSignature(previous)) return false;
|
||||||
|
if (hasMaterialSnapshotChange(current, previous)) return true;
|
||||||
|
|
||||||
|
const previousTime = getSnapshotTime(previous);
|
||||||
|
const nowTime = now.getTime();
|
||||||
|
if (!Number.isFinite(previousTime) || !Number.isFinite(nowTime)) return true;
|
||||||
|
return nowTime - previousTime >= SNAPSHOT_SAVE_THROTTLE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMaterialSnapshotChange(current: XiaobaoRiskSnapshot, previous: XiaobaoRiskSnapshot): boolean {
|
||||||
|
if (current.riskLevel !== previous.riskLevel) return true;
|
||||||
|
if (Math.abs(clampScore(current.riskScore) - clampScore(previous.riskScore)) >= SNAPSHOT_SCORE_DELTA) return true;
|
||||||
|
if ((current.criticalBugCount ?? 0) !== (previous.criticalBugCount ?? 0)) return true;
|
||||||
|
if (current.failedTestCount !== previous.failedTestCount) return true;
|
||||||
|
if (current.blockedCount !== previous.blockedCount) return true;
|
||||||
|
if (current.silentRiskCount !== previous.silentRiskCount) return true;
|
||||||
|
return hasForecastShiftedByOneDay(current.forecastReleaseDate, previous.forecastReleaseDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForecastShiftedByOneDay(current?: string, previous?: string): boolean {
|
||||||
|
if (!current && !previous) return false;
|
||||||
|
if (!current || !previous) return true;
|
||||||
|
const delta = Math.abs(new Date(current).getTime() - new Date(previous).getTime());
|
||||||
|
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
||||||
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
||||||
return Number.isFinite(date) ? date : 0;
|
return Number.isFinite(date) ? date : 0;
|
||||||
|
|||||||
@@ -180,3 +180,17 @@ test('calcXiaobaoVersionRisk preserves null expected release date as a compatibl
|
|||||||
|
|
||||||
assert.equal(risk.expectedReleaseDate, null);
|
assert.equal(risk.expectedReleaseDate, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calcXiaobaoVersionRisk preserves version display context for AI interpretation', () => {
|
||||||
|
const risk = calcXiaobaoVersionRisk({
|
||||||
|
version: version({ name: 'V2.0', productName: 'FTB', projectName: 'Project PM' }),
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
now: new Date('2026-07-02T01:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(risk.versionName, 'V2.0');
|
||||||
|
assert.equal(risk.productName, 'FTB');
|
||||||
|
assert.equal(risk.projectName, 'Project PM');
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { DevTask } from './dev-task';
|
|||||||
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
||||||
import type { TestCase } from './test-case';
|
import type { TestCase } from './test-case';
|
||||||
import { getTestCaseEstimateHours } from './test-case';
|
import { getTestCaseEstimateHours } from './test-case';
|
||||||
|
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||||
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
|
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
|
||||||
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
import { WORK_HOURS, addWorkHours } from './work-hours';
|
import { WORK_HOURS, addWorkHours } from './work-hours';
|
||||||
@@ -14,7 +15,9 @@ export interface XiaobaoVersionRef {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
productId?: string;
|
||||||
productName?: string;
|
productName?: string;
|
||||||
|
projectId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
expectedReleaseDate?: string | null;
|
expectedReleaseDate?: string | null;
|
||||||
members?: Array<{ id?: string; name: string; role?: string }>;
|
members?: Array<{ id?: string; name: string; role?: string }>;
|
||||||
@@ -48,6 +51,11 @@ export interface XiaobaoRiskSignals {
|
|||||||
|
|
||||||
export interface XiaobaoVersionRisk {
|
export interface XiaobaoVersionRisk {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
|
versionName: string;
|
||||||
|
productId?: string;
|
||||||
|
productName?: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectName?: string;
|
||||||
riskScore: number;
|
riskScore: number;
|
||||||
riskLevel: XiaobaoRiskLevel;
|
riskLevel: XiaobaoRiskLevel;
|
||||||
expectedReleaseDate: string | null;
|
expectedReleaseDate: string | null;
|
||||||
@@ -60,10 +68,13 @@ export interface XiaobaoVersionRisk {
|
|||||||
silentRisks: SilentRisk[];
|
silentRisks: SilentRisk[];
|
||||||
dailyEvidence?: VersionDailyEvidence;
|
dailyEvidence?: VersionDailyEvidence;
|
||||||
signals: XiaobaoRiskSignals;
|
signals: XiaobaoRiskSignals;
|
||||||
|
currentSnapshot: XiaobaoRiskSnapshot;
|
||||||
|
aiInsight?: XiaobaoRiskInsight;
|
||||||
trend: {
|
trend: {
|
||||||
direction: 'up' | 'down' | 'flat' | 'unknown';
|
direction: 'up' | 'down' | 'flat' | 'unknown';
|
||||||
delta: number;
|
delta: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,22 +209,29 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
|||||||
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
|
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
|
||||||
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
|
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
|
||||||
const confidence = calcConfidence(input, devTasks, testCases, bugs);
|
const confidence = calcConfidence(input, devTasks, testCases, bugs);
|
||||||
const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], {
|
const currentSnapshot: XiaobaoRiskSnapshot = {
|
||||||
versionId: input.version.id,
|
versionId: input.version.id,
|
||||||
date: now.toISOString().slice(0, 10),
|
date: now.toISOString().slice(0, 10),
|
||||||
riskScore,
|
riskScore,
|
||||||
riskLevel,
|
riskLevel,
|
||||||
forecastReleaseDate,
|
forecastReleaseDate,
|
||||||
openBugCount: signals.openBugCount,
|
openBugCount: signals.openBugCount,
|
||||||
|
criticalBugCount: signals.criticalBugCount,
|
||||||
failedTestCount: signals.failedTestCount,
|
failedTestCount: signals.failedTestCount,
|
||||||
blockedCount: signals.blockedCount,
|
blockedCount: signals.blockedCount,
|
||||||
silentRiskCount: signals.silentRiskCount,
|
silentRiskCount: signals.silentRiskCount,
|
||||||
confidence,
|
confidence,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
});
|
};
|
||||||
|
const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], currentSnapshot);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
versionId: input.version.id,
|
versionId: input.version.id,
|
||||||
|
versionName: input.version.name,
|
||||||
|
productId: input.version.productId,
|
||||||
|
productName: input.version.productName,
|
||||||
|
projectId: input.version.projectId,
|
||||||
|
projectName: input.version.projectName,
|
||||||
riskScore,
|
riskScore,
|
||||||
riskLevel,
|
riskLevel,
|
||||||
expectedReleaseDate: input.version.expectedReleaseDate ?? null,
|
expectedReleaseDate: input.version.expectedReleaseDate ?? null,
|
||||||
@@ -226,6 +244,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
|||||||
silentRisks,
|
silentRisks,
|
||||||
dailyEvidence: input.dailyEvidence,
|
dailyEvidence: input.dailyEvidence,
|
||||||
signals,
|
signals,
|
||||||
|
currentSnapshot,
|
||||||
trend,
|
trend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
152
apps/web/lib/xiaobao-warning-view.test.ts
Normal file
152
apps/web/lib/xiaobao-warning-view.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { VersionWithContext } from './derive';
|
||||||
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import {
|
||||||
|
filterXiaobaoRiskWarnings,
|
||||||
|
filterXiaobaoWarningVersions,
|
||||||
|
formatRemainingWork,
|
||||||
|
getXiaobaoWarningRiskCount,
|
||||||
|
sanitizeRiskInsight,
|
||||||
|
} from './xiaobao-warning-view';
|
||||||
|
|
||||||
|
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
||||||
|
return {
|
||||||
|
id: 'ver-1',
|
||||||
|
name: 'V1.0',
|
||||||
|
status: 'developing',
|
||||||
|
releaseDate: null,
|
||||||
|
createdAt: '2026-06-29T00:00:00.000Z',
|
||||||
|
productId: 'prod-1',
|
||||||
|
productName: 'FTB',
|
||||||
|
projectId: 'proj-1',
|
||||||
|
projectName: 'Project',
|
||||||
|
expectedReleaseDate: '2026-07-05T10:00:00.000Z',
|
||||||
|
members: [{ name: 'Alice', role: 'frontend' }],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
productId: 'prod-1',
|
||||||
|
productName: 'Product A',
|
||||||
|
projectId: 'proj-1',
|
||||||
|
projectName: 'Project A',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
expectedReleaseDate: '2026-07-05',
|
||||||
|
confidence: 80,
|
||||||
|
confidenceLevel: 'high',
|
||||||
|
delayDays: 0,
|
||||||
|
remainingWorkHours: 8,
|
||||||
|
reasons: [],
|
||||||
|
silentRisks: [],
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 1,
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
},
|
||||||
|
currentSnapshot: {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
date: '2026-06-30',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 80,
|
||||||
|
createdAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
|
||||||
|
...patch,
|
||||||
|
} as XiaobaoVersionRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => {
|
||||||
|
const result = filterXiaobaoWarningVersions(
|
||||||
|
[
|
||||||
|
version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-2', status: 'planned', members: [{ name: 'Bob', role: 'testing' }] }),
|
||||||
|
version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
],
|
||||||
|
{ canManage: true, userName: 'Alice' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => {
|
||||||
|
const result = filterXiaobaoWarningVersions(
|
||||||
|
[
|
||||||
|
version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-2', status: 'developing', members: [{ name: 'Bob', role: 'testing' }] }),
|
||||||
|
version({ id: 'ver-3', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
],
|
||||||
|
{ canManage: false, userName: 'Alice' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result.map((item) => item.id), ['ver-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoRiskWarnings hides on_track risks and badge count follows visible risks', () => {
|
||||||
|
const risks = [
|
||||||
|
risk({ versionId: 'ver-1', riskLevel: 'on_track', riskScore: 10 }),
|
||||||
|
risk({ versionId: 'ver-2', riskLevel: 'attention', riskScore: 38 }),
|
||||||
|
risk({ versionId: 'ver-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(filterXiaobaoRiskWarnings(risks).map((item) => item.versionId), ['ver-2', 'ver-3']);
|
||||||
|
assert.equal(getXiaobaoWarningRiskCount(risks), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoRiskWarnings filters by product project and risk tier', () => {
|
||||||
|
const risks = [
|
||||||
|
risk({ versionId: 'ver-1', productId: 'prod-1', projectId: 'proj-1', riskLevel: 'attention', riskScore: 38 }),
|
||||||
|
risk({ versionId: 'ver-2', productId: 'prod-1', projectId: 'proj-2', riskLevel: 'likely_delayed', riskScore: 88 }),
|
||||||
|
risk({ versionId: 'ver-3', productId: 'prod-2', projectId: 'proj-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1' }).map((item) => item.versionId),
|
||||||
|
['ver-1', 'ver-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1', projectId: 'proj-2' }).map((item) => item.versionId),
|
||||||
|
['ver-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { riskFilter: 'high' }).map((item) => item.versionId),
|
||||||
|
['ver-2', 'ver-3'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatRemainingWork keeps hours and adds work-day conversion', () => {
|
||||||
|
assert.equal(formatRemainingWork(0), '0h / 0天');
|
||||||
|
assert.equal(formatRemainingWork(8), '8h / 1天');
|
||||||
|
assert.equal(formatRemainingWork(12), '12h / 1.5天');
|
||||||
|
assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => {
|
||||||
|
const result = sanitizeRiskInsight({
|
||||||
|
summary: '风险上升',
|
||||||
|
why: ['P1 Bug 增加'],
|
||||||
|
forecast: '预计延期 1 天',
|
||||||
|
suggestedActions: [
|
||||||
|
'手动触发页面刷新,等待小宝预警自动更新',
|
||||||
|
'优先处理 3 个 P1 Bug,并同步测试负责人复测',
|
||||||
|
],
|
||||||
|
ownerHints: ['研发负责人协调修复顺序'],
|
||||||
|
generatedAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']);
|
||||||
|
});
|
||||||
80
apps/web/lib/xiaobao-warning-view.ts
Normal file
80
apps/web/lib/xiaobao-warning-view.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { VersionWithContext } from './derive';
|
||||||
|
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||||
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import { WORK_HOURS } from './work-hours';
|
||||||
|
|
||||||
|
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
||||||
|
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
||||||
|
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||||
|
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||||
|
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
||||||
|
/(手动|主动).*(触发|刷新).*(更新|预警|分析)/i,
|
||||||
|
/(manual|manually).*(refresh|reload|trigger)/i,
|
||||||
|
/(refresh|reload).*(page|browser|xiaobao|warning)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface XiaobaoWarningVersionFilter {
|
||||||
|
canManage: boolean;
|
||||||
|
userName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type XiaobaoWarningRiskFilter = 'all' | 'attention' | 'high';
|
||||||
|
|
||||||
|
export interface XiaobaoWarningRiskListFilter {
|
||||||
|
riskFilter?: XiaobaoWarningRiskFilter;
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterXiaobaoWarningVersions(
|
||||||
|
versions: VersionWithContext[],
|
||||||
|
filter: XiaobaoWarningVersionFilter,
|
||||||
|
): VersionWithContext[] {
|
||||||
|
return versions.filter((version) => {
|
||||||
|
if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false;
|
||||||
|
if (filter.canManage) return true;
|
||||||
|
if (!filter.userName) return false;
|
||||||
|
return (version.members ?? []).some((member) => member.name === filter.userName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterXiaobaoRiskWarnings(
|
||||||
|
risks: XiaobaoVersionRisk[],
|
||||||
|
filter: XiaobaoWarningRiskListFilter = {},
|
||||||
|
): XiaobaoVersionRisk[] {
|
||||||
|
return risks.filter((risk) => {
|
||||||
|
if (risk.riskLevel === 'on_track') return false;
|
||||||
|
if (filter.productId && risk.productId !== filter.productId) return false;
|
||||||
|
if (filter.projectId && risk.projectId !== filter.projectId) return false;
|
||||||
|
if (filter.riskFilter === 'attention') return true;
|
||||||
|
if (filter.riskFilter === 'high') return HIGH_RISK_LEVELS.has(risk.riskLevel);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number {
|
||||||
|
return filterXiaobaoRiskWarnings(risks).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemainingWork(hours: number): string {
|
||||||
|
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
|
||||||
|
const days = safeHours / WORK_HOURS.hoursPerDay;
|
||||||
|
return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeRiskInsight(insight: XiaobaoRiskInsight): XiaobaoRiskInsight {
|
||||||
|
return {
|
||||||
|
...insight,
|
||||||
|
suggestedActions: insight.suggestedActions.filter((action) => !isPageRefreshAdvice(action)),
|
||||||
|
ownerHints: insight.ownerHints.filter((hint) => !isPageRefreshAdvice(hint)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
if (Number.isInteger(value)) return String(value);
|
||||||
|
return value.toFixed(1).replace(/\.0$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageRefreshAdvice(text: string): boolean {
|
||||||
|
return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
94
apps/web/stores/useXiaobaoRiskStore.ts
Normal file
94
apps/web/stores/useXiaobaoRiskStore.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||||
|
import {
|
||||||
|
mergeDailySnapshotCacheForSave,
|
||||||
|
mergeInsightCacheForSave,
|
||||||
|
upsertDailySnapshot,
|
||||||
|
upsertInsight,
|
||||||
|
type XiaobaoRiskInsightCacheItem,
|
||||||
|
} from '@/lib/xiaobao-risk-cache';
|
||||||
|
import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
|
|
||||||
|
interface XiaobaoRiskState {
|
||||||
|
snapshots: XiaobaoRiskSnapshot[];
|
||||||
|
insights: XiaobaoRiskInsightCacheItem[];
|
||||||
|
riskDataLoaded: boolean;
|
||||||
|
error?: string;
|
||||||
|
fetchRiskData: () => Promise<void>;
|
||||||
|
saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise<void>;
|
||||||
|
saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshots(): Promise<XiaobaoRiskSnapshot[] | null> {
|
||||||
|
try {
|
||||||
|
const rows = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots');
|
||||||
|
return Array.isArray(rows) ? rows : [];
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInsights(): Promise<XiaobaoRiskInsightCacheItem[] | null> {
|
||||||
|
try {
|
||||||
|
const rows = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights');
|
||||||
|
return Array.isArray(rows) ? rows : [];
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshotSaveQueue: Promise<void> = Promise.resolve();
|
||||||
|
let insightSaveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||||
|
snapshots: [],
|
||||||
|
insights: [],
|
||||||
|
riskDataLoaded: false,
|
||||||
|
error: undefined,
|
||||||
|
|
||||||
|
fetchRiskData: async () => {
|
||||||
|
set({ riskDataLoaded: false });
|
||||||
|
const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]);
|
||||||
|
set({
|
||||||
|
...(snapshots ? { snapshots } : {}),
|
||||||
|
...(insights ? { insights } : {}),
|
||||||
|
riskDataLoaded: snapshots !== null && insights !== null,
|
||||||
|
error: snapshots === null || insights === null ? '小宝预警缓存加载失败' : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSnapshot: async (item) => {
|
||||||
|
const optimistic = upsertDailySnapshot(get().snapshots, item);
|
||||||
|
set({ snapshots: optimistic, error: undefined });
|
||||||
|
const task = snapshotSaveQueue.then(async () => {
|
||||||
|
const remote = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots');
|
||||||
|
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||||
|
set({ snapshots, error: undefined });
|
||||||
|
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||||
|
});
|
||||||
|
snapshotSaveQueue = task.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: '小宝预警快照保存失败' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveInsight: async (item) => {
|
||||||
|
const optimistic = upsertInsight(get().insights, item);
|
||||||
|
set({ insights: optimistic, error: undefined });
|
||||||
|
const task = insightSaveQueue.then(async () => {
|
||||||
|
const remote = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights');
|
||||||
|
const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item);
|
||||||
|
set({ insights, error: undefined });
|
||||||
|
await saveServerData('xiaobao-risk-insights', insights);
|
||||||
|
});
|
||||||
|
insightSaveQueue = task.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: '小宝预警解读保存失败' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -105,9 +105,26 @@
|
|||||||
- 不自动分配 assignee;AI 只提供可选推荐,用户确认采纳后才写入
|
- 不自动分配 assignee;AI 只提供可选推荐,用户确认采纳后才写入
|
||||||
- 不做"上一版基准 diff"
|
- 不做"上一版基准 diff"
|
||||||
|
|
||||||
### Agent 2:Risk Watch Agent(风险预警)— 待规划
|
### Agent 2:Risk Watch Agent(小宝预警解读)
|
||||||
|
|
||||||
仅占位,正式规划见 roadmap.md V3.2。
|
**目的**:解释小宝预警规则引擎输出的版本发版风险结果,生成项目经理可读的风险原因、延期预测、建议发版窗口和处理动作。
|
||||||
|
|
||||||
|
**输入**:
|
||||||
|
- 版本上下文:产品、项目、版本、期望发版日期。
|
||||||
|
- 规则风险结果:`riskScore`、`riskLevel`、`forecastReleaseDate`、`delayDays`、`confidence`、风险信号。
|
||||||
|
- 趋势和快照:风险分变化、连续上升/下降、关键 Bug、失败用例、阻塞和静默风险变化。
|
||||||
|
- 日报与工作活动证据:今日交付、今日进展、今日风险、进展备注、需要补充进展的事项。
|
||||||
|
|
||||||
|
**触发**:
|
||||||
|
- `on_track` 不触发。
|
||||||
|
- `at_risk`、`likely_delayed`、`blocked` 自动触发。
|
||||||
|
- `attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、失败用例增加、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。
|
||||||
|
|
||||||
|
**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。
|
||||||
|
|
||||||
|
**权限**:读规则结果和压缩证据;写 `xiaobao-risk-insights` 缓存。不修改 Version、Requirement、DevTask、TestCase、Bug、Member。
|
||||||
|
|
||||||
|
**失败回退**:AI 不可用时保留规则预警,前端显示“规则预警已生成,AI 解读会在触发条件满足时自动补充”。AI 失败不影响快照保存和规则风险展示。
|
||||||
|
|
||||||
### Agent 3:Schedule Suggest Agent(排期建议)— 待规划
|
### Agent 3:Schedule Suggest Agent(排期建议)— 待规划
|
||||||
|
|
||||||
|
|||||||
@@ -177,3 +177,18 @@ The personal daily report is derived from two inputs:
|
|||||||
`workspace-daily-report.ts` remains a pure aggregation engine. It groups today's current-user activity into delivery, progress, creation, risk, and note sections, and also detects in-progress work that started before today but has no activity or progress note today.
|
`workspace-daily-report.ts` remains a pure aggregation engine. It groups today's current-user activity into delivery, progress, creation, risk, and note sections, and also detects in-progress work that started before today but has no activity or progress note today.
|
||||||
|
|
||||||
This is intentionally not a generic rules engine or event bus. The rule surface is explicit, typed, and local to the workspace/daily-report use case.
|
This is intentionally not a generic rules engine or event bus. The rule surface is explicit, typed, and local to the workspace/daily-report use case.
|
||||||
|
|
||||||
|
## Xiaobao Warning Layer (2026-06-29)
|
||||||
|
|
||||||
|
Xiaobao Warning is a version-level release-risk capability shown above `/workspace` in the main navigation. It answers whether a version can ship on the expected release date, why it may not, roughly how long it may slip, and which release window is safer.
|
||||||
|
|
||||||
|
The rule surface stays in pure frontend engines:
|
||||||
|
|
||||||
|
- `xiaobao-risk.ts`: risk score, level, forecast release date, confidence, and current snapshot.
|
||||||
|
- `xiaobao-risk-evidence.ts`: version work aggregation, daily report/activity evidence, and silent-risk detection.
|
||||||
|
- `xiaobao-risk-trend.ts`: daily snapshots, trend detection, and snapshot signatures.
|
||||||
|
- `xiaobao-risk-ai.ts`: AI trigger policy, cache signature, and backend request mapping.
|
||||||
|
|
||||||
|
Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`.
|
||||||
|
|
||||||
|
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
|
||||||
|
|||||||
@@ -418,3 +418,17 @@
|
|||||||
- TestCase 进入 `running` 前必须具备 `assigneeId`、`plannedTestAt`、`plannedEndAt`。
|
- TestCase 进入 `running` 前必须具备 `assigneeId`、`plannedTestAt`、`plannedEndAt`。
|
||||||
|
|
||||||
**理由**:领取代表成员承诺执行,计划时间代表承诺边界,二者应该在同一个动作里完成。列表层只表达“谁还没接手”,状态机层负责阻止未计划任务进入执行,页面不会被额外标签干扰。
|
**理由**:领取代表成员承诺执行,计划时间代表承诺边界,二者应该在同一个动作里完成。列表层只表达“谁还没接手”,状态机层负责阻止未计划任务进入执行,页面不会被额外标签干扰。
|
||||||
|
|
||||||
|
## 36. 小宝预警规则优先,AI 只做解释
|
||||||
|
|
||||||
|
**问题**:如果直接让 AI 判断版本能否发版,模型可能忽略系统内的任务、Bug、测试、日报和权限事实,结论不可追溯;如果只按风险等级触发 AI,又会漏掉同等级内风险剧变,例如 P1 Bug 从 0 到 3、测试失败、发版日只剩 1 天。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- 小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势、静默风险和证据摘要。
|
||||||
|
- `on_track` 不触发 AI;`at_risk`、`likely_delayed`、`blocked` 自动触发 AI;`attention` 只有在风险分、趋势、关键 Bug、失败用例、阻塞、静默风险、置信度或预测日期出现明显恶化时触发。
|
||||||
|
- AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。
|
||||||
|
- 快照保存需要节流:同版本同日普通变化 10 分钟内不重复保存;风险等级变化、风险分变化达到阈值、关键 Bug/失败用例/阻塞/静默风险变化或预测日期明显变化时立即保存。
|
||||||
|
- AI 解读需要 cooldown:同版本最近 6 小时内已有解读时不重复请求;如果风险等级升级,则允许绕过 cooldown。
|
||||||
|
- AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。
|
||||||
|
|
||||||
|
**理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。
|
||||||
|
|||||||
@@ -97,13 +97,15 @@ NestJS + Prisma + PostgreSQL 已开始接入。第一阶段先用 `app_data` JSO
|
|||||||
- 不做"上一版基准 diff"(按 decisions.md #17 决议)
|
- 不做"上一版基准 diff"(按 decisions.md #17 决议)
|
||||||
- 单 Agent 单 Round,不做多 Agent 编排
|
- 单 Agent 单 Round,不做多 Agent 编排
|
||||||
|
|
||||||
### V3.2 — Risk Watch Agent + Schedule Suggest Agent
|
### V3.2 — 小宝预警 / Risk Watch Agent
|
||||||
|
|
||||||
**Risk Watch Agent**:自动识别延期/阻塞集中/工时偏差大的任务,提前预警
|
小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险、预计可发日期和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版已落地页面触发模式:打开 `/xiaobao-warning` 时保存当天快照,并在满足触发条件时自动生成 AI 解读。
|
||||||
|
|
||||||
**Schedule Suggest Agent**:基于成员负载和历史耗时,建议下一阶段任务分配
|
当前不做后台定时 Agent。后续如果需要主动通知,再在已有 `xiaobao-risk-snapshots` 和 `xiaobao-risk-insights` 基础上增加定时巡检与消息推送。
|
||||||
|
|
||||||
**多 Agent 协作设计**:到 V3.2 才真正涉及,当前 agent-spec.md 仅占位
|
**Schedule Suggest Agent**:基于成员负载和历史耗时,建议下一阶段任务分配,仍作为后续候选。
|
||||||
|
|
||||||
|
**多 Agent 协作设计**:等 Risk Watch 与 Schedule Suggest 都稳定后再设计编排策略。
|
||||||
|
|
||||||
### V3.3 — 其他场景候选
|
### V3.3 — 其他场景候选
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,19 @@ Implementation convention:
|
|||||||
- Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`.
|
- Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`.
|
||||||
- Page components should consume report output, not rebuild report rules.
|
- Page components should consume report output, not rebuild report rules.
|
||||||
|
|
||||||
|
## 小宝预警工作流
|
||||||
|
|
||||||
|
小宝预警位于主导航“工作区 / 小宝预警”,展示在“与我相关”上方。可见范围由角色权限控制:
|
||||||
|
|
||||||
|
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
||||||
|
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
||||||
|
|
||||||
|
页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。快照按同版本同日节流保存:重大变化立即保存,普通变化 10 分钟内不重复写入。
|
||||||
|
|
||||||
|
AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。
|
||||||
|
|
||||||
|
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||||
|
|
||||||
## 日期选择与计划时间
|
## 日期选择与计划时间
|
||||||
|
|
||||||
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
|
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
|
||||||
|
|||||||
@@ -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