diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index f779eb9..d6f4f64 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -18,7 +18,7 @@ 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, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai'; +import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; @@ -45,7 +45,7 @@ function XiaobaoWarningContent() { const { bugs, fetchBugs } = useBugStore(); const { activities, fetchActivities } = useWorkActivityStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); - const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); + const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); const [selectedRiskId, setSelectedRiskId] = useState(null); const [filter, setFilter] = useState('all'); const [calculationNow] = useState(() => new Date()); @@ -142,10 +142,8 @@ function XiaobaoWarningContent() { useEffect(() => { risks.forEach((risk) => { - if (getReusableInsight(insights, risk)) return; const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); - const latestInsight = findLatestRiskInsightForVersion(insights, risk.versionId); - if (!shouldRequestRiskInsightWithCooldown(risk, previous, latestInsight)) return; + if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return; const signature = buildRiskInsightSignature(risk); const key = `${risk.versionId}:${signature}`; if (requestedInsightKeysRef.current.has(key)) return; @@ -161,7 +159,7 @@ function XiaobaoWarningContent() { }).catch(() => {}); }).catch(() => {}); }); - }, [insights, risks, saveInsight, snapshots, today]); + }, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]); const risksWithInsight = useMemo(() => risks.map((risk) => { const cached = getReusableInsight(insights, risk); diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index c9d7262..7fd18c9 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -8,6 +8,7 @@ import { findLatestRiskInsightForVersion, findPreviousRiskSnapshot, shouldRequestRiskInsight, + shouldRequestRiskInsightWithCacheGate, shouldRequestRiskInsightWithCooldown, } from './xiaobao-risk-ai'; import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; @@ -243,6 +244,41 @@ test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level esc 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 }, diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index ae5a8ed..7219dfe 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -90,6 +90,19 @@ export function shouldRequestRiskInsightWithCooldown( 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, diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 960350c..48d3d54 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -13,6 +13,7 @@ import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend'; interface XiaobaoRiskState { snapshots: XiaobaoRiskSnapshot[]; insights: XiaobaoRiskInsightCacheItem[]; + riskDataLoaded: boolean; error?: string; fetchRiskData: () => Promise; saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; @@ -41,13 +42,16 @@ 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, }); },