fix(小宝预警): 保留可复用小宝建议
This commit is contained in:
@@ -361,6 +361,74 @@ test('getReusableInsight reuses legacy signatures with volatile forecast timesta
|
|||||||
assert.equal(getReusableInsight([legacy], current)?.insight.summary, legacy.insight.summary);
|
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', () => {
|
test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => {
|
||||||
const payload = buildRiskInterpretRequest(risk());
|
const payload = buildRiskInterpretRequest(risk());
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ export function getReusableInsight(
|
|||||||
const currentSignature = buildRiskInsightSignature(risk);
|
const currentSignature = buildRiskInsightSignature(risk);
|
||||||
return (
|
return (
|
||||||
findCachedInsight(cache, risk.versionId, currentSignature) ??
|
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 {
|
function normalizeDateKey(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const raw = value.trim();
|
const raw = value.trim();
|
||||||
@@ -325,6 +423,10 @@ function normalizeDaysToExpectedRelease(value: number | null | undefined): numbe
|
|||||||
return value >= 0 ? Math.ceil(value) : Math.floor(value);
|
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 {
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user