fix(小宝预警): 保留可复用小宝建议

This commit is contained in:
Script Generator
2026-06-30 12:05:41 +08:00
parent 92e230d303
commit a6a4d7d3d9
2 changed files with 171 additions and 1 deletions

View File

@@ -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());

View File

@@ -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<string, unknown>;
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<Record<string, unknown>> {
return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : [];
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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;