fix(小宝预警): 稳定AI建议缓存签名

This commit is contained in:
Script Generator
2026-06-30 10:03:12 +08:00
parent eb9996a987
commit 0ca13d406c
2 changed files with 78 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import {
buildRiskInterpretRequest, buildRiskInterpretRequest,
findLatestRiskInsightForVersion, findLatestRiskInsightForVersion,
findPreviousRiskSnapshot, findPreviousRiskSnapshot,
getReusableInsight,
shouldRequestRiskInsight, shouldRequestRiskInsight,
shouldRequestRiskInsightWithCacheGate, shouldRequestRiskInsightWithCacheGate,
shouldRequestRiskInsightWithCooldown, shouldRequestRiskInsightWithCooldown,
@@ -328,6 +329,38 @@ test('buildRiskInsightSignature changes when trend direction delta or activity m
assert.notEqual(base, changed); 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', () => { test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => {
const payload = buildRiskInterpretRequest(risk()); const payload = buildRiskInterpretRequest(risk());

View File

@@ -108,8 +108,8 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
versionId: risk.versionId, versionId: risk.versionId,
riskScore: clampScore(risk.riskScore), riskScore: clampScore(risk.riskScore),
riskLevel: risk.riskLevel, riskLevel: risk.riskLevel,
expectedReleaseDate: risk.expectedReleaseDate ?? null, expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate),
forecastReleaseDate: risk.forecastReleaseDate ?? null, forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate),
delayDays: risk.delayDays, delayDays: risk.delayDays,
confidence: clampScore(risk.confidence), confidence: clampScore(risk.confidence),
signals: { signals: {
@@ -119,7 +119,7 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
failedTestCount: risk.signals.failedTestCount, failedTestCount: risk.signals.failedTestCount,
blockedCount: risk.signals.blockedCount, blockedCount: risk.signals.blockedCount,
silentRiskCount: risk.signals.silentRiskCount, silentRiskCount: risk.signals.silentRiskCount,
daysToExpectedRelease: risk.signals.daysToExpectedRelease ?? null, daysToExpectedRelease: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease),
}, },
trend: { trend: {
direction: risk.trend.direction, direction: risk.trend.direction,
@@ -145,7 +145,11 @@ export function getReusableInsight(
cache: XiaobaoRiskInsightCacheItem[], cache: XiaobaoRiskInsightCacheItem[],
risk: XiaobaoVersionRisk, risk: XiaobaoVersionRisk,
): XiaobaoRiskInsightCacheItem | undefined { ): 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( 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 { function getTime(value: string): number {
const time = new Date(value).getTime(); const time = new Date(value).getTime();
return Number.isFinite(time) ? time : Number.NaN; return Number.isFinite(time) ? time : Number.NaN;