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:
Script Generator
2026-06-30 10:45:31 +08:00
27 changed files with 2901 additions and 27 deletions

View File

@@ -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);
}
} }

View File

@@ -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_usecontent=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; 'Anthropic 未通过 tool_use 返回结果stop_reason=tool_usecontent=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
function createRiskRequest(patch: Partial<AgentRiskInterpretRequest> = {}): AgentRiskInterpretRequest {
return {
versionId: 'ver-1',
versionName: 'V1.0',
riskScore: 70,
riskLevel: 'at_risk',
delayDays: 1,
confidence: 80,
signals: {
unfinishedCount: 4,
openBugCount: 3,
criticalBugCount: 3,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 0,
daysToExpectedRelease: 1,
},
trendSummary: 'risk rising',
reasons: [{ key: 'critical_bug', label: 'Critical Bug', severity: 'critical', detail: '3 P1 bugs' }],
silentRisks: [],
dailyEvidence: {
todayDeliveries: [],
todayProgress: [],
todayRisks: ['tests failed'],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 1,
},
...patch,
};
}
describe('AiService', () => { 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()

View File

@@ -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('原型链接无效');

View File

@@ -0,0 +1,142 @@
import { Type } from 'class-transformer';
import { IsArray, IsDefined, IsIn, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
class RiskReasonDto {
@IsString()
key!: string;
@IsString()
label!: string;
@IsIn(['low', 'medium', 'high', 'critical'])
severity!: 'low' | 'medium' | 'high' | 'critical';
@IsString()
detail!: string;
}
class SilentRiskDto {
@IsString()
key!: string;
@IsOptional()
@IsNumber()
days?: number;
@IsString()
detail!: string;
}
class RiskDailyEvidenceDto {
@IsArray()
@IsString({ each: true })
todayDeliveries!: string[];
@IsArray()
@IsString({ each: true })
todayProgress!: string[];
@IsArray()
@IsString({ each: true })
todayRisks!: string[];
@IsArray()
@IsString({ each: true })
progressNotes!: string[];
@IsArray()
@IsString({ each: true })
needsProgressItems!: string[];
@IsNumber()
recentActivityCount!: number;
@IsOptional()
@IsString()
lastActivityAt?: string;
}
class RiskSignalsDto {
@IsNumber()
unfinishedCount!: number;
@IsNumber()
openBugCount!: number;
@IsNumber()
criticalBugCount!: number;
@IsNumber()
failedTestCount!: number;
@IsNumber()
blockedCount!: number;
@IsNumber()
silentRiskCount!: number;
@IsOptional()
@IsNumber()
daysToExpectedRelease?: number;
}
export class RiskInterpretDto {
@IsString()
versionId!: string;
@IsString()
versionName!: string;
@IsOptional()
@IsString()
productName?: string;
@IsOptional()
@IsString()
projectName?: string;
@IsNumber()
riskScore!: number;
@IsIn(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked'])
riskLevel!: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
@IsOptional()
@IsString()
expectedReleaseDate?: string;
@IsOptional()
@IsString()
forecastReleaseDate?: string;
@IsNumber()
delayDays!: number;
@IsNumber()
confidence!: number;
@IsDefined()
@IsObject()
@ValidateNested()
@Type(() => RiskSignalsDto)
signals!: RiskSignalsDto;
@IsString()
trendSummary!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => RiskReasonDto)
reasons!: RiskReasonDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => SilentRiskDto)
silentRisks!: SilentRiskDto[];
@IsDefined()
@IsObject()
@ValidateNested()
@Type(() => RiskDailyEvidenceDto)
dailyEvidence!: RiskDailyEvidenceDto;
}

View File

@@ -0,0 +1,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,
};

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

View File

@@ -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>
);
}

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

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

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

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

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

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

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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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');
});

View File

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

View 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并同步测试负责人复测']);
});

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

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

View File

@@ -105,9 +105,26 @@
- 不自动分配 assigneeAI 只提供可选推荐,用户确认采纳后才写入 - 不自动分配 assigneeAI 只提供可选推荐,用户确认采纳后才写入
- 不做"上一版基准 diff" - 不做"上一版基准 diff"
### Agent 2Risk Watch Agent风险预警)— 待规划 ### Agent 2Risk 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 3Schedule Suggest Agent排期建议— 待规划 ### Agent 3Schedule Suggest Agent排期建议— 待规划

View File

@@ -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.

View File

@@ -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 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。

View File

@@ -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 — 其他场景候选

View File

@@ -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 创建时使用统一工作日日期时间选择器。

View File

@@ -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';