fix(小宝预警): 增加快照节流和 AI cooldown

This commit is contained in:
Script Generator
2026-06-30 09:28:12 +08:00
parent 37695a8101
commit bf4b32f17d
7 changed files with 207 additions and 8 deletions

View File

@@ -18,9 +18,9 @@ import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { flattenVersions } from '@/lib/derive';
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
import { buildRiskInsightSignature, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
import { buildRiskSignature } from '@/lib/xiaobao-risk-trend';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
type RiskFilter = 'all' | 'attention' | 'high';
@@ -128,7 +128,9 @@ function XiaobaoWarningContent() {
useEffect(() => {
risks.forEach((risk) => {
const snapshot = risk.currentSnapshot;
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);
@@ -136,13 +138,14 @@ function XiaobaoWarningContent() {
savedSnapshotKeysRef.current.delete(key);
});
});
}, [risks, saveSnapshot]);
}, [risks, saveSnapshot, snapshots]);
useEffect(() => {
risks.forEach((risk) => {
if (getReusableInsight(insights, risk)) return;
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
if (!shouldRequestRiskInsight(risk, previous)) return;
const latestInsight = findLatestRiskInsightForVersion(insights, risk.versionId);
if (!shouldRequestRiskInsightWithCooldown(risk, previous, latestInsight)) return;
const signature = buildRiskInsightSignature(risk);
const key = `${risk.versionId}:${signature}`;
if (requestedInsightKeysRef.current.has(key)) return;

View File

@@ -5,9 +5,12 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import {
buildRiskInsightSignature,
buildRiskInterpretRequest,
findLatestRiskInsightForVersion,
findPreviousRiskSnapshot,
shouldRequestRiskInsight,
shouldRequestRiskInsightWithCooldown,
} from './xiaobao-risk-ai';
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
return {
@@ -69,6 +72,23 @@ function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
} as unknown as XiaobaoVersionRisk;
}
function insight(patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
return {
versionId: 'ver-1',
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
insight: {
summary: 'Risk needs attention.',
why: ['Risk rose.'],
forecast: 'May slip.',
suggestedActions: ['Confirm scope.'],
ownerHints: ['PM'],
generatedAt: '2026-06-29T08:00:00.000Z',
},
generatedAt: '2026-06-29T08:00:00.000Z',
...patch,
};
}
test('shouldRequestRiskInsight skips on_track', () => {
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
});
@@ -184,6 +204,45 @@ test('shouldRequestRiskInsight triggers high risk levels', () => {
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('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
const base = buildRiskInsightSignature(risk({
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },

View File

@@ -24,6 +24,14 @@ type RiskInsightPrevious = Pick<
const SCORE_TRIGGER_DELTA = 15;
const CONFIDENCE_DROP_DELTA = 15;
const ONE_DAY_MS = 86_400_000;
const RISK_INSIGHT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
const RISK_LEVEL_RANK: Record<XiaobaoVersionRisk['riskLevel'], number> = {
on_track: 0,
attention: 1,
at_risk: 2,
likely_delayed: 3,
blocked: 4,
};
export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
if (current.riskLevel === 'on_track') return false;
@@ -57,6 +65,31 @@ export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?:
);
}
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 buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
return JSON.stringify({
versionId: risk.versionId,
@@ -219,3 +252,26 @@ function compareSignatureRows<T>(a: T, b: T): number {
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 getTime(value: string): number {
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : Number.NaN;
}

View File

@@ -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>): XiaobaoRiskSnapshot {
@@ -48,6 +48,38 @@ test('buildRiskSignature changes when critical bug count changes without open bu
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({

View File

@@ -22,6 +22,10 @@ export interface RiskTrendSummary {
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) {
@@ -85,6 +89,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;