diff --git a/apps/server/src/modules/ai/ai.controller.ts b/apps/server/src/modules/ai/ai.controller.ts index 40a8933..4f43c7a 100644 --- a/apps/server/src/modules/ai/ai.controller.ts +++ b/apps/server/src/modules/ai/ai.controller.ts @@ -1,7 +1,13 @@ import { Body, Controller, Post } from '@nestjs/common'; import { AiService } from './ai.service'; import { DecomposeDto } from './dto/decompose.dto'; -import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared'; +import { RiskInterpretDto } from './dto/risk-interpret.dto'; +import type { + AgentDecomposeResponse, + AgentDecomposeError, + AgentRiskInterpretResponse, + AgentRiskInterpretError, +} from '@ftb/shared'; @Controller('ai') export class AiController { @@ -11,4 +17,9 @@ export class AiController { async decompose(@Body() dto: DecomposeDto): Promise { return this.aiService.decompose(dto); } + + @Post('risk-interpret') + async interpretRisk(@Body() dto: RiskInterpretDto): Promise { + return this.aiService.interpretRisk(dto); + } } diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 7e57b79..670479b 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -1,11 +1,267 @@ import { AiService } from './ai.service'; import type { AiGatewayService } from './ai-gateway.service'; import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; +import { RISK_INTERPRET_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 = 'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; +function createRiskRequest(patch: Partial = {}): AgentRiskInterpretRequest { + return { + versionId: 'ver-1', + versionName: 'V1.0', + riskScore: 70, + riskLevel: 'at_risk', + delayDays: 1, + confidence: 80, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 3, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + trendSummary: 'risk rising', + reasons: [{ key: 'critical_bug', label: 'Critical Bug', severity: 'critical', detail: '3 P1 bugs' }], + silentRisks: [], + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayRisks: ['tests failed'], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + }, + ...patch, + }; +} + describe('AiService', () => { + it('interprets version risk through the active provider', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: { + summary: '预计延期', + why: ['P1 Bug 增加'], + forecast: '预计 2026-07-04 可发', + recommendedReleaseWindow: '2026-07-04 之后', + suggestedActions: ['优先修复 P1 Bug'], + ownerHints: ['测试负责人同步失败用例'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }); + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk({ + versionId: 'ver-1', + versionName: 'V1.0', + riskScore: 70, + riskLevel: 'at_risk', + delayDays: 1, + confidence: 80, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 3, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + trendSummary: '风险上升', + reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }], + silentRisks: [], + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayRisks: ['测试不通过'], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.result.summary).toBe('预计延期'); + expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ + forceTool: true, + maxTokens: 3000, + tool: expect.objectContaining({ name: 'submit_risk_interpretation' }), + })); + expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升'); + }); + + it('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 () => { const callTool = jest .fn() diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 655c8f4..535ca87 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -6,6 +6,12 @@ import { DECOMPOSE_TOOL_DESCRIPTION, DECOMPOSE_TOOL_INPUT_SCHEMA, } from './prompts/decompose'; +import { + RISK_INTERPRET_SYSTEM_PROMPT, + RISK_INTERPRET_TOOL_NAME, + RISK_INTERPRET_TOOL_DESCRIPTION, + RISK_INTERPRET_TOOL_INPUT_SCHEMA, +} from './prompts/risk-interpret'; import { buildPrototypeContext } from './prototype-context'; import type { AgentDecomposeRequest, @@ -13,12 +19,23 @@ import type { AgentDecomposeError, AgentDecomposeResult, AgentDecomposeTarget, + AgentRiskInsight, + AgentRiskInterpretError, + AgentRiskInterpretRequest, + AgentRiskInterpretResponse, } from '@ftb/shared'; const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; const PROTOTYPE_FETCH_TIMEOUT_MS = 15000; const PROTOTYPE_FETCH_ATTEMPTS = 3; 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() export class AiService { @@ -129,6 +146,127 @@ export class AiService { }; } + async interpretRisk(req: AgentRiskInterpretRequest): Promise { + const startedAt = Date.now(); + let provider; + let model: string; + try { + provider = await this.gateway.getActiveProvider(); + model = await this.gateway.getActiveModel(); + } catch (e: any) { + return { + ok: false, + error: e?.message || '未配置 AI 提供商', + code: 'NO_PROVIDER', + }; + } + + try { + const toolResp = await provider.callTool({ + systemPrompt: RISK_INTERPRET_SYSTEM_PROMPT, + userPrompt: `请基于以下小宝预警规则结果生成解释,禁止编造新事实:\n${JSON.stringify(req, null, 2)}`, + tool: { + name: RISK_INTERPRET_TOOL_NAME, + description: RISK_INTERPRET_TOOL_DESCRIPTION, + inputSchema: RISK_INTERPRET_TOOL_INPUT_SCHEMA, + }, + forceTool: true, + maxTokens: 3000, + model, + }); + const result = this.parseRiskInsightToolResponse(toolResp); + if (!result) { + return { + ok: false, + error: 'AI 返回结构不符合预期', + code: 'PARSE_ERROR', + }; + } + return { + ok: true, + result, + meta: { + model: toolResp.rawModel || model, + inputTokens: toolResp.inputTokens, + outputTokens: toolResp.outputTokens, + durationMs: Date.now() - startedAt, + }, + }; + } catch (e: any) { + if (this.isProviderParseError(e)) { + return { + ok: false, + error: e?.message || 'AI 返回结构不符合预期', + code: 'PARSE_ERROR', + }; + } + this.logger.error(`AI 风险解读调用失败: ${e.message}`); + return { + ok: false, + error: `AI 服务调用失败:${e.message}`, + code: 'API_ERROR', + }; + } + } + + private parseRiskInsightToolResponse(toolResp: any): AgentRiskInsight | null { + if (!toolResp || toolResp.toolName !== RISK_INTERPRET_TOOL_NAME || !this.isRecord(toolResp.toolInput)) { + return null; + } + + const input = toolResp.toolInput; + const summary = this.readString(input.summary); + const forecast = this.readString(input.forecast); + const generatedAt = this.readString(input.generatedAt); + const why = this.readStringArray(input.why); + const suggestedActions = this.readStringArray(input.suggestedActions); + const ownerHints = this.readStringArray(input.ownerHints); + if (!summary || !forecast || !generatedAt || !why || !suggestedActions || !ownerHints) { + return null; + } + + const insight: AgentRiskInsight = { + summary, + why, + forecast, + suggestedActions: 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private isProviderParseError(error: any): boolean { + const message = String(error?.message || ''); + return message.includes('tool_call arguments') && message.includes('JSON'); + } + private async fetchPrototype(url: string): Promise { if (!url || !url.startsWith('http')) { throw new Error('原型链接无效'); diff --git a/apps/server/src/modules/ai/dto/risk-interpret.dto.ts b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts new file mode 100644 index 0000000..093eeb0 --- /dev/null +++ b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts @@ -0,0 +1,142 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDefined, IsIn, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +class RiskReasonDto { + @IsString() + key!: string; + + @IsString() + label!: string; + + @IsIn(['low', 'medium', 'high', 'critical']) + severity!: 'low' | 'medium' | 'high' | 'critical'; + + @IsString() + detail!: string; +} + +class SilentRiskDto { + @IsString() + key!: string; + + @IsOptional() + @IsNumber() + days?: number; + + @IsString() + detail!: string; +} + +class RiskDailyEvidenceDto { + @IsArray() + @IsString({ each: true }) + todayDeliveries!: string[]; + + @IsArray() + @IsString({ each: true }) + todayProgress!: string[]; + + @IsArray() + @IsString({ each: true }) + todayRisks!: string[]; + + @IsArray() + @IsString({ each: true }) + progressNotes!: string[]; + + @IsArray() + @IsString({ each: true }) + needsProgressItems!: string[]; + + @IsNumber() + recentActivityCount!: number; + + @IsOptional() + @IsString() + lastActivityAt?: string; +} + +class RiskSignalsDto { + @IsNumber() + unfinishedCount!: number; + + @IsNumber() + openBugCount!: number; + + @IsNumber() + criticalBugCount!: number; + + @IsNumber() + failedTestCount!: number; + + @IsNumber() + blockedCount!: number; + + @IsNumber() + silentRiskCount!: number; + + @IsOptional() + @IsNumber() + daysToExpectedRelease?: number; +} + +export class RiskInterpretDto { + @IsString() + versionId!: string; + + @IsString() + versionName!: string; + + @IsOptional() + @IsString() + productName?: string; + + @IsOptional() + @IsString() + projectName?: string; + + @IsNumber() + riskScore!: number; + + @IsIn(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']) + riskLevel!: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + + @IsOptional() + @IsString() + expectedReleaseDate?: string; + + @IsOptional() + @IsString() + forecastReleaseDate?: string; + + @IsNumber() + delayDays!: number; + + @IsNumber() + confidence!: number; + + @IsDefined() + @IsObject() + @ValidateNested() + @Type(() => RiskSignalsDto) + signals!: RiskSignalsDto; + + @IsString() + trendSummary!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RiskReasonDto) + reasons!: RiskReasonDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SilentRiskDto) + silentRisks!: SilentRiskDto[]; + + @IsDefined() + @IsObject() + @ValidateNested() + @Type(() => RiskDailyEvidenceDto) + dailyEvidence!: RiskDailyEvidenceDto; +} diff --git a/apps/server/src/modules/ai/prompts/risk-interpret.ts b/apps/server/src/modules/ai/prompts/risk-interpret.ts new file mode 100644 index 0000000..b5a8c67 --- /dev/null +++ b/apps/server/src/modules/ai/prompts/risk-interpret.ts @@ -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, +}; diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx new file mode 100644 index 0000000..dcbdcb7 --- /dev/null +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -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 = { + on_track: '按期', + attention: '关注', + at_risk: '有风险', + likely_delayed: '大概率延期', + blocked: '阻塞', +}; + +const RISK_LEVEL_STYLE: Record = { + 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 ( + + + + ); +} + +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(null); + const [selectedProductId, setSelectedProductId] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const savedSnapshotKeysRef = useRef(new Set()); + const requestedInsightKeysRef = useRef(new Set()); + + 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 ( +
+
+
+
+ +
+
+

小宝预警

+

+ {canManage ? '管理视角:全部未结束版本' : '个人视角:我参与的未结束版本'} +

+
+
+
+ {canManage && ( + + + 管理权限 + + )} +
+

平均置信

+

{avgConfidence}%

+
+
+
+ +
+ + +
+ {selectedRisk ? ( + router.push(`/versions/${selectedRisk.versionId}`)} + /> + ) : ( +
+ 选择左侧风险版本查看详情 +
+ )} +
+
+
+ ); +} + +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 ( +
+
+
+

{risk.productName ?? '-'} / {risk.projectName ?? '-'}

+
+

{risk.versionName}

+ +
+
+ +
+ +
+ = 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} /> + + 0 ? 'warn' : 'ok'} /> + 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} /> +
+ +
+ {risk.aiInsight ? ( +
+
+

+ + {risk.aiInsight.summary} +

+

{risk.aiInsight.forecast}

+ {risk.aiInsight.recommendedReleaseWindow && ( +

{risk.aiInsight.recommendedReleaseWindow}

+ )} +
+
+ {risk.aiInsight.suggestedActions.map((action) => ( +
+ {action} +
+ ))} +
+
+ ) : ( + + )} +
+ +
+
+
+

+ + 期望发版:{formatDateTime(risk.expectedReleaseDate)} +

+

预测可发:{formatDateTime(risk.forecastReleaseDate)}

+

预计延期:{risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'}

+
+

{risk.trend.summary}

+
+
+ +
+
+
+ setDetailTab('reasons')}> + 风险原因 {risk.reasons.length} + + setDetailTab('silent')}> + 静默风险 {risk.silentRisks.length} + + setDetailTab('evidence')}> + 日报与活动证据 {evidenceItems.length} + +
+
+ {detailTab === 'reasons' && ( +
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => ( +
+
+

{reason.title}

+ {reason.severity} +
+

{reason.detail}

+
+ ))} +
+ )} + + {detailTab === 'silent' && ( +
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => ( +
+

{item.title}

+

{item.detail}

+
+ ))} +
+ )} + + {detailTab === 'evidence' && ( +
+ {evidenceItems.length === 0 ? : evidenceItems.map((item) => ( +
+

{item.title}

+

{item.summary}

+

{formatDateTime(item.occurredAt)}

+
+ ))} +
+ )} +
+
+
+
+ ); +} + +function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) { + return ( + + {RISK_LEVEL_LABEL[level]} + + ); +} + +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 ( +
+

{label}

+

{value}

+
+ ); +} + +function SelectFilter({ + label, + value, + allLabel, + options, + onChange, +}: { + label: string; + value: string; + allLabel: string; + options: FilterOption[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +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(); + 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)); +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 9568337..20cac0c 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,15 +1,26 @@ 'use client'; 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 { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useAuthStore } from '@/stores/useAuthStore'; 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 = [ { label: '工作区', 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: '/products', icon: Package, permission: 'product:view' }, { label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' }, @@ -103,7 +114,7 @@ function UserBlock() { function NavGroup({ label, items, isActive, onNavigate }: { label: string; - items: { label: string; path: string; icon: any; permission: string | null }[]; + items: NavItemConfig[]; isActive: (p: string) => boolean; onNavigate: (p: string) => void; }) { @@ -120,7 +131,7 @@ function NavGroup({ label, items, isActive, onNavigate }: { } function NavItem({ item, active, onNavigate }: { - item: { label: string; path: string; icon: any; permission: string | null }; + item: NavItemConfig; active: boolean; onNavigate: (p: string) => void; }) { @@ -137,7 +148,20 @@ function NavItem({ item, active, onNavigate }: { }`} > - {item.label} + {item.label} + {item.badge === 'xiaobao-risk' && } ); } + +function XiaobaoRiskNavBadge() { + const { risks } = useXiaobaoWarningRisks(); + const count = getXiaobaoWarningRiskCount(risks); + if (count <= 0) return null; + + return ( + + {count > 99 ? '99+' : count} + + ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx new file mode 100644 index 0000000..6828c09 --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -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 = { + on_track: '按期', + attention: '关注', + at_risk: '有风险', + likely_delayed: '大概率延期', + blocked: '阻塞', +}; + +const RISK_LEVEL_STYLE: Record = { + 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 ( + + ); +} + +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 ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx new file mode 100644 index 0000000..4fdebd9 --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -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 ( +
+
+
+ {risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName} +
+
+
+

小宝预警详情

+

规则预警会保留事实证据,AI 解读自动补充

+
+ +
+ +
+
+ + + + 0 ? `${risk.delayDays}天` : '0天'} /> +
+ +
+
+

+ + 期望发版: {formatDateTime(risk.expectedReleaseDate)} +

+

预测可发: {formatDateTime(risk.forecastReleaseDate)}

+

{risk.trend.summary}

+
+
+ +
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => ( +
+
+

{reason.title}

+ {reason.severity} +
+

{reason.detail}

+
+ ))} +
+ +
+ {evidenceItems.length === 0 ? : evidenceItems.map((item) => ( +
+

{item.title}

+

{item.summary}

+

{formatDateTime(item.occurredAt)}

+
+ ))} +
+ +
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => ( +
+

{item.title}

+

{item.detail}

+
+ ))} +
+ +
+ {risk.aiInsight ? ( +
+
+

+ + {risk.aiInsight.summary} +

+

{risk.aiInsight.forecast}

+
+ {risk.aiInsight.suggestedActions.map((action) => ( +
+ {action} +
+ ))} +
+ ) : ( + + )} +
+ + +
+
+
+ ); +} + +function Summary({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/apps/web/hooks/useXiaobaoWarningRisks.ts b/apps/web/hooks/useXiaobaoWarningRisks.ts new file mode 100644 index 0000000..7400f1c --- /dev/null +++ b/apps/web/hooks/useXiaobaoWarningRisks.ts @@ -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(); + 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, + }; +} diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts new file mode 100644 index 0000000..7058054 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -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 { + 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 { + 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 { + 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), + ), + 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); +}); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts new file mode 100644 index 0000000..b9123f8 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -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 = { + 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 { + return api.postRaw( + '/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(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; +} diff --git a/apps/web/lib/xiaobao-risk-cache.test.ts b/apps/web/lib/xiaobao-risk-cache.test.ts new file mode 100644 index 0000000..a50ff65 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-cache.test.ts @@ -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]); +}); diff --git a/apps/web/lib/xiaobao-risk-cache.ts b/apps/web/lib/xiaobao-risk-cache.ts new file mode 100644 index 0000000..98f7b9b --- /dev/null +++ b/apps/web/lib/xiaobao-risk-cache.ts @@ -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); +} diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts index 42ca8af..72cad8a 100644 --- a/apps/web/lib/xiaobao-risk-trend.test.ts +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import type { Bug } from './bug'; 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'; function snapshot(patch: Partial): XiaobaoRiskSnapshot { @@ -41,6 +41,45 @@ test('buildRiskSignature changes when score and bug counts change', () => { 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', () => { const now = new Date('2026-07-02T01:00:00.000Z'); 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.criticalBugCount, 0); - assert.equal(currentSignature, buildRiskSignature({ - versionId: risk.versionId, - date: now.toISOString().slice(0, 10), - riskScore: risk.riskScore, - riskLevel: risk.riskLevel, - forecastReleaseDate: risk.forecastReleaseDate, - openBugCount: 1, - failedTestCount: 0, - blockedCount: 0, - silentRiskCount: 0, - confidence: risk.confidence, - createdAt: now.toISOString(), - })); + assert.equal(risk.currentSnapshot.openBugCount, 1); + assert.equal(risk.currentSnapshot.criticalBugCount, 0); + assert.equal(currentSignature, buildRiskSignature(risk.currentSnapshot)); + assert.equal(buildRiskSignature(risk.currentSnapshot).split('|')[5], '1'); }); function bug(patch: Partial = {}): Bug { diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts index ade3cd0..658f8c2 100644 --- a/apps/web/lib/xiaobao-risk-trend.ts +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot { riskLevel: XiaobaoRiskLevel; forecastReleaseDate?: string; openBugCount: number; + criticalBugCount?: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; @@ -22,6 +23,10 @@ export interface RiskTrendSummary { 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 { const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b)); if (sorted.length < 2) { @@ -80,6 +85,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { snapshot.riskLevel, snapshot.forecastReleaseDate ?? '', snapshot.openBugCount, + snapshot.criticalBugCount ?? 0, snapshot.failedTestCount, snapshot.blockedCount, snapshot.silentRiskCount, @@ -87,6 +93,49 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { ].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 { const date = new Date(snapshot.createdAt || snapshot.date).getTime(); return Number.isFinite(date) ? date : 0; diff --git a/apps/web/lib/xiaobao-risk.test.ts b/apps/web/lib/xiaobao-risk.test.ts index 51f4e38..9a2372e 100644 --- a/apps/web/lib/xiaobao-risk.test.ts +++ b/apps/web/lib/xiaobao-risk.test.ts @@ -180,3 +180,17 @@ test('calcXiaobaoVersionRisk preserves null expected release date as a compatibl 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'); +}); diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 7e93b8c..7f96555 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -3,6 +3,7 @@ import type { DevTask } from './dev-task'; import { STATUS_PROGRESS, getEstimateHours } from './dev-task'; import type { TestCase } from './test-case'; import { getTestCaseEstimateHours } from './test-case'; +import type { XiaobaoRiskInsight } from './xiaobao-risk-cache'; import type { VersionDailyEvidence } from './xiaobao-risk-evidence'; import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { WORK_HOURS, addWorkHours } from './work-hours'; @@ -14,7 +15,9 @@ export interface XiaobaoVersionRef { id: string; name: string; status?: string; + productId?: string; productName?: string; + projectId?: string; projectName?: string; expectedReleaseDate?: string | null; members?: Array<{ id?: string; name: string; role?: string }>; @@ -48,6 +51,11 @@ export interface XiaobaoRiskSignals { export interface XiaobaoVersionRisk { versionId: string; + versionName: string; + productId?: string; + productName?: string; + projectId?: string; + projectName?: string; riskScore: number; riskLevel: XiaobaoRiskLevel; expectedReleaseDate: string | null; @@ -60,10 +68,13 @@ export interface XiaobaoVersionRisk { silentRisks: SilentRisk[]; dailyEvidence?: VersionDailyEvidence; signals: XiaobaoRiskSignals; + currentSnapshot: XiaobaoRiskSnapshot; + aiInsight?: XiaobaoRiskInsight; trend: { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; 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 riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk); const confidence = calcConfidence(input, devTasks, testCases, bugs); - const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], { + const currentSnapshot: XiaobaoRiskSnapshot = { versionId: input.version.id, date: now.toISOString().slice(0, 10), riskScore, riskLevel, forecastReleaseDate, openBugCount: signals.openBugCount, + criticalBugCount: signals.criticalBugCount, failedTestCount: signals.failedTestCount, blockedCount: signals.blockedCount, silentRiskCount: signals.silentRiskCount, confidence, createdAt: now.toISOString(), - }); + }; + const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], currentSnapshot); return { 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, riskLevel, expectedReleaseDate: input.version.expectedReleaseDate ?? null, @@ -226,6 +244,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao silentRisks, dailyEvidence: input.dailyEvidence, signals, + currentSnapshot, trend, }; } diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts new file mode 100644 index 0000000..7d918d4 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -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 { + 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 { + 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,并同步测试负责人复测']); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts new file mode 100644 index 0000000..d7cd459 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -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(['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)); +} diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts new file mode 100644 index 0000000..48d3d54 --- /dev/null +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -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; + saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; + saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise; +} + +async function loadSnapshots(): Promise { + try { + const rows = await loadServerData('xiaobao-risk-snapshots'); + return Array.isArray(rows) ? rows : []; + } catch {} + return null; +} + +async function loadInsights(): Promise { + try { + const rows = await loadServerData('xiaobao-risk-insights'); + return Array.isArray(rows) ? rows : []; + } catch {} + return null; +} + +let snapshotSaveQueue: Promise = Promise.resolve(); +let insightSaveQueue: Promise = Promise.resolve(); + +export const useXiaobaoRiskStore = create((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('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('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; + } + }, +})); diff --git a/docs/agent-spec.md b/docs/agent-spec.md index 09ebb15..af14fe5 100644 --- a/docs/agent-spec.md +++ b/docs/agent-spec.md @@ -105,9 +105,26 @@ - 不自动分配 assignee;AI 只提供可选推荐,用户确认采纳后才写入 - 不做"上一版基准 diff" -### Agent 2:Risk Watch Agent(风险预警)— 待规划 +### Agent 2:Risk Watch Agent(小宝预警解读) -仅占位,正式规划见 roadmap.md V3.2。 +**目的**:解释小宝预警规则引擎输出的版本发版风险结果,生成项目经理可读的风险原因、延期预测、建议发版窗口和处理动作。 + +**输入**: +- 版本上下文:产品、项目、版本、期望发版日期。 +- 规则风险结果:`riskScore`、`riskLevel`、`forecastReleaseDate`、`delayDays`、`confidence`、风险信号。 +- 趋势和快照:风险分变化、连续上升/下降、关键 Bug、失败用例、阻塞和静默风险变化。 +- 日报与工作活动证据:今日交付、今日进展、今日风险、进展备注、需要补充进展的事项。 + +**触发**: +- `on_track` 不触发。 +- `at_risk`、`likely_delayed`、`blocked` 自动触发。 +- `attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、失败用例增加、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。 + +**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。 + +**权限**:读规则结果和压缩证据;写 `xiaobao-risk-insights` 缓存。不修改 Version、Requirement、DevTask、TestCase、Bug、Member。 + +**失败回退**:AI 不可用时保留规则预警,前端显示“规则预警已生成,AI 解读会在触发条件满足时自动补充”。AI 失败不影响快照保存和规则风险展示。 ### Agent 3:Schedule Suggest Agent(排期建议)— 待规划 diff --git a/docs/architecture.md b/docs/architecture.md index 9ab453f..7a456a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. 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. diff --git a/docs/decisions.md b/docs/decisions.md index 06db04a..b049802 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -418,3 +418,17 @@ - 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 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 96afb61..fdf87bb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -97,13 +97,15 @@ NestJS + Prisma + PostgreSQL 已开始接入。第一阶段先用 `app_data` JSO - 不做"上一版基准 diff"(按 decisions.md #17 决议) - 单 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 — 其他场景候选 diff --git a/docs/workflow.md b/docs/workflow.md index 876e224..eac4705 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -242,6 +242,19 @@ Implementation convention: - Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`. - 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 创建时使用统一工作日日期时间选择器。 diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 8dcaa5c..4676b8f 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -114,6 +114,74 @@ export interface AgentDecomposeError { code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; } +export interface AgentRiskReason { + key: string; + label: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + detail: string; +} + +export interface AgentRiskInterpretRequest { + versionId: string; + versionName: string; + productName?: string; + projectName?: string; + riskScore: number; + riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + expectedReleaseDate?: string | null; + forecastReleaseDate?: string; + delayDays: number; + confidence: number; + signals: { + unfinishedCount: number; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; + }; + trendSummary: string; + reasons: AgentRiskReason[]; + silentRisks: Array<{ key: string; days?: number; detail: string }>; + dailyEvidence: { + todayDeliveries: string[]; + todayProgress: string[]; + todayRisks: string[]; + progressNotes: string[]; + needsProgressItems: string[]; + recentActivityCount: number; + lastActivityAt?: string; + }; +} + +export interface AgentRiskInsight { + summary: string; + why: string[]; + forecast: string; + recommendedReleaseWindow?: string; + suggestedActions: string[]; + ownerHints: string[]; + generatedAt: string; +} + +export interface AgentRiskInterpretResponse { + ok: true; + result: AgentRiskInsight; + meta: { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + }; +} + +export interface AgentRiskInterpretError { + ok: false; + error: string; + code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; +} + /* ───────────────────────── AI Provider 配置 ─────────────────────────── */ export type AiProviderFormat = 'anthropic' | 'openai';