diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 7fd18c9..7058054 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -7,6 +7,7 @@ import { buildRiskInterpretRequest, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, + getReusableInsight, shouldRequestRiskInsight, shouldRequestRiskInsightWithCacheGate, shouldRequestRiskInsightWithCooldown, @@ -328,6 +329,38 @@ test('buildRiskInsightSignature changes when trend direction delta or activity m 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()); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index 7219dfe..b9123f8 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -108,8 +108,8 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { versionId: risk.versionId, riskScore: clampScore(risk.riskScore), riskLevel: risk.riskLevel, - expectedReleaseDate: risk.expectedReleaseDate ?? null, - forecastReleaseDate: risk.forecastReleaseDate ?? null, + expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate), + forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate), delayDays: risk.delayDays, confidence: clampScore(risk.confidence), signals: { @@ -119,7 +119,7 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { failedTestCount: risk.signals.failedTestCount, blockedCount: risk.signals.blockedCount, silentRiskCount: risk.signals.silentRiskCount, - daysToExpectedRelease: risk.signals.daysToExpectedRelease ?? null, + daysToExpectedRelease: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease), }, trend: { direction: risk.trend.direction, @@ -145,7 +145,11 @@ export function getReusableInsight( cache: XiaobaoRiskInsightCacheItem[], risk: XiaobaoVersionRisk, ): XiaobaoRiskInsightCacheItem | undefined { - return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk)); + const currentSignature = buildRiskInsightSignature(risk); + return ( + findCachedInsight(cache, risk.versionId, currentSignature) ?? + cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature) + ); } export function findPreviousRiskSnapshot( @@ -284,6 +288,43 @@ function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | un } } +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;