diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 7058054..2565aea 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -361,6 +361,74 @@ test('getReusableInsight reuses legacy signatures with volatile forecast timesta assert.equal(getReusableInsight([legacy], current)?.insight.summary, legacy.insight.summary); }); +test('getReusableInsight keeps showing cached insight when only refresh-volatile fields drift', () => { + const cachedRisk = risk({ + riskLevel: 'likely_delayed', + riskScore: 100, + confidence: 65, + forecastReleaseDate: '2026-07-14T03:37:15.903Z', + signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 }, + dailyEvidence: { + ...risk().dailyEvidence!, + recentActivityCount: 12, + lastActivityAt: '2026-06-29T03:37:15.903Z', + todayProgress: [ + { id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' }, + ], + }, + currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }), + }); + const current = risk({ + riskLevel: 'likely_delayed', + riskScore: 100, + confidence: 65, + forecastReleaseDate: '2026-07-15T06:21:38.597Z', + signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 0 }, + dailyEvidence: { + ...risk().dailyEvidence!, + recentActivityCount: 13, + lastActivityAt: '2026-06-29T06:21:38.597Z', + todayProgress: [ + { id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' }, + ], + }, + currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }), + }); + const cached = insight({ + riskSignature: buildRiskInsightSignature(cachedRisk), + insight: { ...insight().insight, summary: 'Cached Xiaobao advice stays visible.' }, + generatedAt: '2026-06-29T08:00:00.000Z', + }); + + assert.equal(getReusableInsight([cached], current)?.insight.summary, 'Cached Xiaobao advice stays visible.'); + assert.equal( + shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 70 }), new Date('2026-06-29T09:00:00.000Z')), + false, + ); +}); + +test('getReusableInsight does not reuse cached insight when core risk facts change', () => { + const cachedRisk = risk({ + riskLevel: 'likely_delayed', + riskScore: 100, + confidence: 65, + forecastReleaseDate: '2026-07-14T03:37:15.903Z', + signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 }, + currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }), + }); + const current = risk({ + riskLevel: 'likely_delayed', + riskScore: 100, + confidence: 65, + forecastReleaseDate: '2026-07-15T06:21:38.597Z', + signals: { ...risk().signals, failedTestCount: 15, silentRiskCount: 58, daysToExpectedRelease: 0 }, + currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 15, silentRiskCount: 58 }), + }); + const cached = insight({ riskSignature: buildRiskInsightSignature(cachedRisk) }); + + assert.equal(getReusableInsight([cached], current), undefined); +}); + 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 b9123f8..ae6e6eb 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -148,7 +148,8 @@ export function getReusableInsight( const currentSignature = buildRiskInsightSignature(risk); return ( findCachedInsight(cache, risk.versionId, currentSignature) ?? - cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature) + cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature) ?? + findLatestDisplayCompatibleInsight(cache, risk.versionId, currentSignature) ); } @@ -309,6 +310,103 @@ function normalizeCachedRiskSignature(signature: string): string | undefined { } } +function findLatestDisplayCompatibleInsight( + cache: XiaobaoRiskInsightCacheItem[], + versionId: string, + currentSignature: string, +): XiaobaoRiskInsightCacheItem | undefined { + const currentKey = buildDisplayCompatibilityKey(currentSignature); + if (!currentKey) return undefined; + return cache + .filter((item) => item.versionId === versionId && buildDisplayCompatibilityKey(item.riskSignature) === currentKey) + .sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0]; +} + +function buildDisplayCompatibilityKey(signature: string): string | undefined { + try { + const parsed = JSON.parse(signature) as Record; + const signals = readRecord(parsed.signals); + const trend = readRecord(parsed.trend); + const dailyEvidence = readRecord(parsed.dailyEvidence); + + return JSON.stringify({ + versionId: readString(parsed.versionId), + riskScore: normalizeInteger(parsed.riskScore), + riskLevel: readString(parsed.riskLevel), + expectedReleaseDate: normalizeDateKey(readString(parsed.expectedReleaseDate)), + delayDays: normalizeInteger(parsed.delayDays), + confidence: normalizeInteger(parsed.confidence), + signals: { + unfinishedCount: normalizeInteger(signals.unfinishedCount), + openBugCount: normalizeInteger(signals.openBugCount), + criticalBugCount: normalizeInteger(signals.criticalBugCount), + failedTestCount: normalizeInteger(signals.failedTestCount), + blockedCount: normalizeInteger(signals.blockedCount), + silentRiskCount: normalizeInteger(signals.silentRiskCount), + }, + trend: { + direction: readString(trend.direction), + delta: normalizeInteger(trend.delta), + pattern: readString(trend.pattern), + }, + reasons: normalizeCompatibilityReasons(parsed.reasons), + silentRisks: normalizeCompatibilitySilentRisks(parsed.silentRisks), + dailyEvidence: { + todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries), + todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress), + todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks), + progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes), + needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems), + }, + }); + } catch { + return undefined; + } +} + +function normalizeCompatibilityReasons(rows: unknown) { + return asRecordArray(rows) + .map((row) => ({ + key: readString(row.key), + severity: readString(row.severity), + detail: readString(row.detail), + count: normalizeInteger(row.count), + })) + .sort(compareSignatureRows); +} + +function normalizeCompatibilitySilentRisks(rows: unknown) { + return asRecordArray(rows) + .map((row) => ({ + key: readString(row.key), + itemId: readString(row.itemId), + itemType: readString(row.itemType), + })) + .sort(compareSignatureRows); +} + +function normalizeCompatibilityEvidence(rows: unknown) { + return asRecordArray(rows) + .map((row) => ({ + id: readString(row.id), + title: readString(row.title), + summary: readString(row.summary), + })) + .sort(compareSignatureRows); +} + +function asRecordArray(rows: unknown): Array> { + return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : []; +} + +function readRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + function normalizeDateKey(value: string | null | undefined): string | null { if (!value) return null; const raw = value.trim(); @@ -325,6 +423,10 @@ function normalizeDaysToExpectedRelease(value: number | null | undefined): numbe return value >= 0 ? Math.ceil(value) : Math.floor(value); } +function normalizeInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null; +} + function getTime(value: string): number { const time = new Date(value).getTime(); return Number.isFinite(time) ? time : Number.NaN;