import type { XiaobaoRiskLevel } from './xiaobao-risk'; export interface XiaobaoRiskSnapshot { versionId: string; date: string; riskScore: number; riskLevel: XiaobaoRiskLevel; forecastReleaseDate?: string; openBugCount: number; criticalBugCount?: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; confidence: number; createdAt: string; } export interface RiskTrendSummary { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; summary: string; pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; } 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) { return { direction: 'unknown', delta: 0, summary: 'No historical risk snapshots yet.', pattern: 'unknown' }; } const first = sorted[0]; const latest = sorted[sorted.length - 1]; const delta = clampScore(latest.riskScore) - clampScore(first.riskScore); const recentThree = sorted.slice(-3); if (recentThree.length === 3) { const [a, b, c] = recentThree.map((snapshot) => clampScore(snapshot.riskScore)); if (a < b && b < c) { return { direction: 'up', delta, summary: `Risk rose continuously from ${a} to ${c}.`, pattern: 'continuous_rising', }; } if (a > b && b > c) { return { direction: 'down', delta, summary: `Risk fell continuously from ${a} to ${c}.`, pattern: 'continuous_falling', }; } } if (delta >= 10) { return { direction: 'up', delta, summary: `Risk rose by ${delta} points.`, pattern: 'score_delta' }; } if (delta <= -10) { return { direction: 'down', delta, summary: `Risk fell by ${Math.abs(delta)} points.`, pattern: 'score_delta' }; } return { direction: 'flat', delta, summary: 'Risk is stable.', pattern: 'stable' }; } export function summarizeRiskTrendWithCurrent( snapshots: XiaobaoRiskSnapshot[] = [], current: XiaobaoRiskSnapshot, ): RiskTrendSummary { return summarizeRiskTrend([...snapshots, current]); } export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { return [ snapshot.versionId, snapshot.date, clampScore(snapshot.riskScore), snapshot.riskLevel, snapshot.forecastReleaseDate ?? '', snapshot.openBugCount, snapshot.criticalBugCount ?? 0, snapshot.failedTestCount, snapshot.blockedCount, snapshot.silentRiskCount, clampScore(snapshot.confidence), ].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; } function clampScore(score: number): number { return Math.max(0, Math.min(100, Math.round(score))); }