merge: 合并小宝预警到 master
# Conflicts: # apps/web/lib/xiaobao-risk-trend.test.ts # apps/web/lib/xiaobao-risk.ts # docs/decisions.md
This commit is contained in:
389
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
389
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
import {
|
||||
buildRiskInsightSignature,
|
||||
buildRiskInterpretRequest,
|
||||
findLatestRiskInsightForVersion,
|
||||
findPreviousRiskSnapshot,
|
||||
getReusableInsight,
|
||||
shouldRequestRiskInsight,
|
||||
shouldRequestRiskInsightWithCacheGate,
|
||||
shouldRequestRiskInsightWithCooldown,
|
||||
} from './xiaobao-risk-ai';
|
||||
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||
|
||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
|
||||
return {
|
||||
versionId: 'ver-1',
|
||||
date: '2026-06-28',
|
||||
riskScore: 35,
|
||||
riskLevel: 'attention',
|
||||
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
|
||||
openBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-06-28T10:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||
return {
|
||||
versionId: 'ver-1',
|
||||
versionName: 'V1.0',
|
||||
productName: 'FTB',
|
||||
projectName: 'PM',
|
||||
riskScore: 68,
|
||||
riskLevel: 'attention',
|
||||
expectedReleaseDate: '2026-07-01T10:00:00.000Z',
|
||||
confidence: 80,
|
||||
confidenceLevel: 'high',
|
||||
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
|
||||
delayDays: 1,
|
||||
remainingWorkHours: 12,
|
||||
reasons: [
|
||||
{ key: 'failed_test', title: 'Failed tests', detail: '1 test failed.', severity: 'warning', count: 1 },
|
||||
{ key: 'critical_bug', title: 'Critical bug', detail: 'P1 bug exists.', severity: 'danger', count: 1 },
|
||||
],
|
||||
silentRisks: [{ key: 'no_update', title: 'No update', detail: 'No update for 5 days.' }],
|
||||
dailyEvidence: {
|
||||
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }],
|
||||
todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }],
|
||||
todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }],
|
||||
progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }],
|
||||
needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }],
|
||||
recentActivityCount: 3,
|
||||
lastActivityAt: '2026-06-29T13:00:00.000Z',
|
||||
},
|
||||
signals: {
|
||||
unfinishedCount: 4,
|
||||
openBugCount: 3,
|
||||
criticalBugCount: 1,
|
||||
failedTestCount: 1,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 1,
|
||||
daysToExpectedRelease: 1,
|
||||
},
|
||||
currentSnapshot: snapshot({ riskScore: 68, openBugCount: 3, failedTestCount: 1, silentRiskCount: 1 }),
|
||||
trend: { direction: 'up', delta: 33, summary: 'Risk rose by 33 points.', pattern: 'score_delta' },
|
||||
...patch,
|
||||
} as unknown as XiaobaoVersionRisk;
|
||||
}
|
||||
|
||||
function insight(patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
|
||||
return {
|
||||
versionId: 'ver-1',
|
||||
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
|
||||
insight: {
|
||||
summary: 'Risk needs attention.',
|
||||
why: ['Risk rose.'],
|
||||
forecast: 'May slip.',
|
||||
suggestedActions: ['Confirm scope.'],
|
||||
ownerHints: ['PM'],
|
||||
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||
},
|
||||
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('shouldRequestRiskInsight skips on_track', () => {
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight does not trigger ordinary attention without a previous worsening signal', () => {
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 42, signals: { ...risk().signals, unfinishedCount: 0, daysToExpectedRelease: 5 } }), undefined), false);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 68 }), snapshot({ riskScore: 35 })), true);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
|
||||
assert.equal(
|
||||
shouldRequestRiskInsight(
|
||||
risk({
|
||||
riskLevel: 'attention',
|
||||
riskScore: 52,
|
||||
signals: {
|
||||
unfinishedCount: 4,
|
||||
openBugCount: 3,
|
||||
criticalBugCount: 3,
|
||||
failedTestCount: 1,
|
||||
blockedCount: 1,
|
||||
silentRiskCount: 1,
|
||||
daysToExpectedRelease: 1,
|
||||
},
|
||||
}),
|
||||
snapshot({ riskScore: 50, openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention when critical bugs increase even if open bug total is unchanged', () => {
|
||||
assert.equal(
|
||||
shouldRequestRiskInsight(
|
||||
risk({
|
||||
riskLevel: 'attention',
|
||||
riskScore: 45,
|
||||
signals: {
|
||||
unfinishedCount: 0,
|
||||
openBugCount: 2,
|
||||
criticalBugCount: 1,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
daysToExpectedRelease: 5,
|
||||
},
|
||||
}),
|
||||
snapshot({ riskScore: 44, openBugCount: 2, criticalBugCount: 0 } as Partial<XiaobaoRiskSnapshot>),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention on continuously rising trend', () => {
|
||||
assert.equal(
|
||||
shouldRequestRiskInsight(
|
||||
risk({
|
||||
riskLevel: 'attention',
|
||||
riskScore: 46,
|
||||
signals: {
|
||||
unfinishedCount: 0,
|
||||
openBugCount: 0,
|
||||
criticalBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
daysToExpectedRelease: 5,
|
||||
},
|
||||
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||
}),
|
||||
snapshot({ riskScore: 38 }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention on continuously rising trend without previous snapshot', () => {
|
||||
assert.equal(
|
||||
shouldRequestRiskInsight(
|
||||
risk({
|
||||
riskLevel: 'attention',
|
||||
riskScore: 46,
|
||||
signals: {
|
||||
unfinishedCount: 0,
|
||||
openBugCount: 0,
|
||||
criticalBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
daysToExpectedRelease: 5,
|
||||
},
|
||||
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||
}),
|
||||
undefined,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers attention near release with unfinished work even without history', () => {
|
||||
assert.equal(
|
||||
shouldRequestRiskInsight(risk({ riskLevel: 'attention', signals: { ...risk().signals, unfinishedCount: 2, daysToExpectedRelease: 1 } }), undefined),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsight triggers high risk levels', () => {
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'at_risk' }), undefined), true);
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'likely_delayed' }), undefined), true);
|
||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), undefined), true);
|
||||
});
|
||||
|
||||
test('findLatestRiskInsightForVersion returns latest generated insight for a version', () => {
|
||||
const latest = findLatestRiskInsightForVersion([
|
||||
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T08:00:00.000Z' }),
|
||||
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T10:00:00.000Z' }),
|
||||
insight({ versionId: 'ver-2', generatedAt: '2026-06-29T11:00:00.000Z' }),
|
||||
], 'ver-1');
|
||||
|
||||
assert.equal(latest?.generatedAt, '2026-06-29T10:00:00.000Z');
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCooldown skips repeated AI requests inside cooldown', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
|
||||
const previous = snapshot({ riskScore: 40 });
|
||||
const latest = insight({
|
||||
generatedAt: '2026-06-29T10:00:00.000Z',
|
||||
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 60 })),
|
||||
});
|
||||
|
||||
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), false);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCooldown allows AI requests after cooldown', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
|
||||
const previous = snapshot({ riskScore: 40 });
|
||||
const latest = insight({ generatedAt: '2026-06-29T04:00:00.000Z' });
|
||||
|
||||
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), true);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level escalates', () => {
|
||||
const previousInsight = insight({
|
||||
generatedAt: '2026-06-29T10:30:00.000Z',
|
||||
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
|
||||
});
|
||||
const current = risk({ riskLevel: 'blocked', riskScore: 90 });
|
||||
|
||||
assert.equal(shouldRequestRiskInsightWithCooldown(current, snapshot({ riskScore: 50 }), previousInsight, new Date('2026-06-29T11:00:00.000Z')), true);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCacheGate waits for cache loading before AI requests', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||
|
||||
assert.equal(
|
||||
shouldRequestRiskInsightWithCacheGate(false, [], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCacheGate reuses unchanged cached insight after cache loading', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||
const cached = insight({
|
||||
riskSignature: buildRiskInsightSignature(current),
|
||||
generatedAt: '2026-06-29T02:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cache loading and cooldown', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||
const previousInsight = insight({
|
||||
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 62 })),
|
||||
generatedAt: '2026-06-29T02:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
shouldRequestRiskInsightWithCacheGate(true, [previousInsight], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
||||
const base = buildRiskInsightSignature(risk({
|
||||
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
||||
currentSnapshot: snapshot({ openBugCount: 1, riskScore: 45 }),
|
||||
}));
|
||||
const changed = buildRiskInsightSignature(risk({
|
||||
signals: { ...risk().signals, openBugCount: 2, criticalBugCount: 0 },
|
||||
currentSnapshot: snapshot({ openBugCount: 2, riskScore: 45 }),
|
||||
}));
|
||||
|
||||
assert.notEqual(base, changed);
|
||||
});
|
||||
|
||||
test('buildRiskInsightSignature changes when trend or evidence changes', () => {
|
||||
const base = buildRiskInsightSignature(risk({
|
||||
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
|
||||
}));
|
||||
const changed = buildRiskInsightSignature(risk({
|
||||
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
|
||||
dailyEvidence: {
|
||||
...risk().dailyEvidence!,
|
||||
todayRisks: [{ id: 'ev-new', title: 'Risk', summary: 'New P1 regression appeared.', occurredAt: '2026-06-29T15:00:00.000Z' }],
|
||||
},
|
||||
}));
|
||||
|
||||
assert.notEqual(base, changed);
|
||||
});
|
||||
|
||||
test('buildRiskInsightSignature changes when trend direction delta or activity metadata changes', () => {
|
||||
const base = buildRiskInsightSignature(risk({
|
||||
trend: { direction: 'up', delta: 10, summary: 'Risk changed.', pattern: 'score_delta' },
|
||||
dailyEvidence: {
|
||||
...risk().dailyEvidence!,
|
||||
recentActivityCount: 1,
|
||||
lastActivityAt: '2026-06-29T10:00:00.000Z',
|
||||
},
|
||||
}));
|
||||
const changed = buildRiskInsightSignature(risk({
|
||||
trend: { direction: 'down', delta: -10, summary: 'Risk changed.', pattern: 'score_delta' },
|
||||
dailyEvidence: {
|
||||
...risk().dailyEvidence!,
|
||||
recentActivityCount: 2,
|
||||
lastActivityAt: '2026-06-29T11:00:00.000Z',
|
||||
},
|
||||
}));
|
||||
|
||||
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());
|
||||
|
||||
assert.equal(payload.versionName, 'V1.0');
|
||||
assert.equal(payload.reasons[0].label, 'Failed tests');
|
||||
assert.equal(payload.reasons[0].severity, 'medium');
|
||||
assert.equal(payload.reasons[1].severity, 'critical');
|
||||
assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']);
|
||||
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
||||
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
||||
});
|
||||
|
||||
test('findPreviousRiskSnapshot returns latest snapshot before today for the version', () => {
|
||||
const previous = findPreviousRiskSnapshot(
|
||||
[
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 70, createdAt: '2026-06-29T10:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, createdAt: '2026-06-28T10:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, createdAt: '2026-06-27T10:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-2', date: '2026-06-28', riskScore: 90, createdAt: '2026-06-28T11:00:00.000Z' }),
|
||||
],
|
||||
'ver-1',
|
||||
'2026-06-29',
|
||||
);
|
||||
|
||||
assert.equal(previous?.riskScore, 52);
|
||||
});
|
||||
331
apps/web/lib/xiaobao-risk-ai.ts
Normal file
331
apps/web/lib/xiaobao-risk-ai.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { AgentRiskInterpretError, AgentRiskInterpretRequest, AgentRiskInterpretResponse } from '@ftb/shared';
|
||||
import { api } from './api';
|
||||
import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import { findCachedInsight, type XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
|
||||
type RiskInsightCurrent = Pick<
|
||||
XiaobaoVersionRisk,
|
||||
'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend'
|
||||
>;
|
||||
|
||||
type RiskInsightPrevious = Pick<
|
||||
XiaobaoRiskSnapshot,
|
||||
| 'riskScore'
|
||||
| 'confidence'
|
||||
| 'forecastReleaseDate'
|
||||
| 'openBugCount'
|
||||
| 'criticalBugCount'
|
||||
| 'failedTestCount'
|
||||
| 'blockedCount'
|
||||
| 'silentRiskCount'
|
||||
>;
|
||||
|
||||
const SCORE_TRIGGER_DELTA = 15;
|
||||
const CONFIDENCE_DROP_DELTA = 15;
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
const RISK_INSIGHT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||
const RISK_LEVEL_RANK: Record<XiaobaoVersionRisk['riskLevel'], number> = {
|
||||
on_track: 0,
|
||||
attention: 1,
|
||||
at_risk: 2,
|
||||
likely_delayed: 3,
|
||||
blocked: 4,
|
||||
};
|
||||
|
||||
export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
|
||||
if (current.riskLevel === 'on_track') return false;
|
||||
if (current.riskLevel === 'at_risk' || current.riskLevel === 'likely_delayed' || current.riskLevel === 'blocked') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const releaseIsNearWithUnfinishedWork =
|
||||
current.signals.daysToExpectedRelease !== undefined &&
|
||||
current.signals.daysToExpectedRelease <= 1 &&
|
||||
current.signals.unfinishedCount > 0;
|
||||
if (!previous) return isWorseningTrend(current) || releaseIsNearWithUnfinishedWork;
|
||||
|
||||
const scoreDelta = current.riskScore - previous.riskScore;
|
||||
const confidenceDrop = previous.confidence - current.confidence;
|
||||
const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate
|
||||
? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime()
|
||||
: 0;
|
||||
|
||||
return (
|
||||
scoreDelta >= SCORE_TRIGGER_DELTA ||
|
||||
confidenceDrop >= CONFIDENCE_DROP_DELTA ||
|
||||
forecastDelayMs >= ONE_DAY_MS ||
|
||||
isWorseningTrend(current) ||
|
||||
current.signals.openBugCount > previous.openBugCount ||
|
||||
current.signals.criticalBugCount > (previous.criticalBugCount ?? 0) ||
|
||||
current.signals.failedTestCount > previous.failedTestCount ||
|
||||
current.signals.blockedCount > previous.blockedCount ||
|
||||
current.signals.silentRiskCount > previous.silentRiskCount ||
|
||||
releaseIsNearWithUnfinishedWork
|
||||
);
|
||||
}
|
||||
|
||||
export function findLatestRiskInsightForVersion(
|
||||
cache: XiaobaoRiskInsightCacheItem[],
|
||||
versionId: string,
|
||||
): XiaobaoRiskInsightCacheItem | undefined {
|
||||
return cache
|
||||
.filter((item) => item.versionId === versionId)
|
||||
.sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0];
|
||||
}
|
||||
|
||||
export function shouldRequestRiskInsightWithCooldown(
|
||||
current: RiskInsightCurrent,
|
||||
previous?: RiskInsightPrevious,
|
||||
latestInsight?: XiaobaoRiskInsightCacheItem,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!shouldRequestRiskInsight(current, previous)) return false;
|
||||
if (!latestInsight) return true;
|
||||
if (isRiskLevelEscalation(current.riskLevel, latestInsight)) return true;
|
||||
|
||||
const generatedAt = getTime(latestInsight.generatedAt);
|
||||
const nowTime = now.getTime();
|
||||
if (!Number.isFinite(generatedAt) || !Number.isFinite(nowTime)) return true;
|
||||
return nowTime - generatedAt >= RISK_INSIGHT_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
export function shouldRequestRiskInsightWithCacheGate(
|
||||
riskCacheLoaded: boolean,
|
||||
cache: XiaobaoRiskInsightCacheItem[],
|
||||
current: XiaobaoVersionRisk,
|
||||
previous?: RiskInsightPrevious,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!riskCacheLoaded) return false;
|
||||
if (getReusableInsight(cache, current)) return false;
|
||||
const latestInsight = findLatestRiskInsightForVersion(cache, current.versionId);
|
||||
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
||||
}
|
||||
|
||||
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||
return JSON.stringify({
|
||||
versionId: risk.versionId,
|
||||
riskScore: clampScore(risk.riskScore),
|
||||
riskLevel: risk.riskLevel,
|
||||
expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate),
|
||||
forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate),
|
||||
delayDays: risk.delayDays,
|
||||
confidence: clampScore(risk.confidence),
|
||||
signals: {
|
||||
unfinishedCount: risk.signals.unfinishedCount,
|
||||
openBugCount: risk.signals.openBugCount,
|
||||
criticalBugCount: risk.signals.criticalBugCount,
|
||||
failedTestCount: risk.signals.failedTestCount,
|
||||
blockedCount: risk.signals.blockedCount,
|
||||
silentRiskCount: risk.signals.silentRiskCount,
|
||||
daysToExpectedRelease: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease),
|
||||
},
|
||||
trend: {
|
||||
direction: risk.trend.direction,
|
||||
delta: risk.trend.delta,
|
||||
summary: risk.trend.summary,
|
||||
pattern: risk.trend.pattern ?? null,
|
||||
},
|
||||
reasons: normalizeReasons(risk.reasons),
|
||||
silentRisks: normalizeSilentRisks(risk.silentRisks),
|
||||
dailyEvidence: {
|
||||
todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||
todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getReusableInsight(
|
||||
cache: XiaobaoRiskInsightCacheItem[],
|
||||
risk: XiaobaoVersionRisk,
|
||||
): XiaobaoRiskInsightCacheItem | undefined {
|
||||
const currentSignature = buildRiskInsightSignature(risk);
|
||||
return (
|
||||
findCachedInsight(cache, risk.versionId, currentSignature) ??
|
||||
cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature)
|
||||
);
|
||||
}
|
||||
|
||||
export function findPreviousRiskSnapshot(
|
||||
snapshots: XiaobaoRiskSnapshot[],
|
||||
versionId: string,
|
||||
today: string,
|
||||
): XiaobaoRiskSnapshot | undefined {
|
||||
return snapshots
|
||||
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date < today)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
||||
}
|
||||
|
||||
export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest {
|
||||
return {
|
||||
versionId: risk.versionId,
|
||||
versionName: risk.versionName,
|
||||
productName: risk.productName,
|
||||
projectName: risk.projectName,
|
||||
riskScore: risk.riskScore,
|
||||
riskLevel: risk.riskLevel,
|
||||
expectedReleaseDate: risk.expectedReleaseDate,
|
||||
forecastReleaseDate: risk.forecastReleaseDate,
|
||||
delayDays: risk.delayDays,
|
||||
confidence: risk.confidence,
|
||||
signals: risk.signals,
|
||||
trendSummary: risk.trend.summary,
|
||||
reasons: risk.reasons.map(mapRiskReason),
|
||||
silentRisks: risk.silentRisks.map((item) => ({
|
||||
key: item.key,
|
||||
detail: item.detail,
|
||||
})),
|
||||
dailyEvidence: {
|
||||
todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||
todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestRiskInsight(
|
||||
risk: XiaobaoVersionRisk,
|
||||
): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||
return api.postRaw<AgentRiskInterpretResponse | AgentRiskInterpretError>(
|
||||
'/ai/risk-interpret',
|
||||
buildRiskInterpretRequest(risk),
|
||||
120000,
|
||||
);
|
||||
}
|
||||
|
||||
function mapRiskReason(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number] {
|
||||
return {
|
||||
key: reason.key,
|
||||
label: reason.title,
|
||||
severity: mapReasonSeverity(reason),
|
||||
detail: reason.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function mapReasonSeverity(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number]['severity'] {
|
||||
if (reason.severity === 'info') return 'low';
|
||||
if (reason.severity === 'warning') return 'medium';
|
||||
if (reason.key === 'critical_bug' || reason.key === 'blocked_work') return 'critical';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
function summarizeEvidence(items: Array<{ summary: string }> | undefined): string[] {
|
||||
return (items ?? []).map((item) => item.summary).filter((summary) => summary.trim().length > 0);
|
||||
}
|
||||
|
||||
function isWorseningTrend(current: RiskInsightCurrent): boolean {
|
||||
return current.trend.direction === 'up' && current.trend.pattern === 'continuous_rising';
|
||||
}
|
||||
|
||||
function normalizeReasons(reasons: XiaobaoVersionRisk['reasons']) {
|
||||
return reasons
|
||||
.map((reason) => ({
|
||||
key: reason.key,
|
||||
title: reason.title,
|
||||
severity: reason.severity,
|
||||
detail: reason.detail,
|
||||
count: reason.count ?? null,
|
||||
}))
|
||||
.sort(compareSignatureRows);
|
||||
}
|
||||
|
||||
function normalizeSilentRisks(silentRisks: XiaobaoVersionRisk['silentRisks']) {
|
||||
return silentRisks
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
detail: item.detail,
|
||||
itemId: item.itemId ?? null,
|
||||
itemType: item.itemType ?? null,
|
||||
}))
|
||||
.sort(compareSignatureRows);
|
||||
}
|
||||
|
||||
function normalizeEvidence(items: Array<{ id?: string; title?: string; summary: string; occurredAt?: string }> | undefined) {
|
||||
return (items ?? [])
|
||||
.map((item) => ({
|
||||
id: item.id ?? null,
|
||||
title: item.title ?? null,
|
||||
summary: item.summary,
|
||||
occurredAt: item.occurredAt ?? null,
|
||||
}))
|
||||
.sort(compareSignatureRows);
|
||||
}
|
||||
|
||||
function compareSignatureRows<T>(a: T, b: T): number {
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
}
|
||||
|
||||
function clampScore(score: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
function isRiskLevelEscalation(
|
||||
currentLevel: XiaobaoVersionRisk['riskLevel'],
|
||||
latestInsight: XiaobaoRiskInsightCacheItem,
|
||||
): boolean {
|
||||
const previousLevel = parseRiskLevel(latestInsight.riskSignature);
|
||||
if (!previousLevel) return false;
|
||||
return RISK_LEVEL_RANK[currentLevel] > RISK_LEVEL_RANK[previousLevel];
|
||||
}
|
||||
|
||||
function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoVersionRisk['riskLevel'] };
|
||||
return parsed.riskLevel && parsed.riskLevel in RISK_LEVEL_RANK ? parsed.riskLevel : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
103
apps/web/lib/xiaobao-risk-cache.test.ts
Normal file
103
apps/web/lib/xiaobao-risk-cache.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
import {
|
||||
findCachedInsight,
|
||||
mergeDailySnapshotCacheForSave,
|
||||
mergeInsightCacheForSave,
|
||||
upsertDailySnapshot,
|
||||
upsertInsight,
|
||||
type XiaobaoRiskInsightCacheItem,
|
||||
} from './xiaobao-risk-cache';
|
||||
|
||||
const insightA: XiaobaoRiskInsightCacheItem = {
|
||||
versionId: 'v-1',
|
||||
riskSignature: 'sig-a',
|
||||
insight: {
|
||||
summary: '版本风险上升',
|
||||
why: ['剩余工作较多'],
|
||||
forecast: '可能延期 2 天',
|
||||
suggestedActions: ['压缩低优先级范围'],
|
||||
ownerHints: ['请项目负责人确认排期'],
|
||||
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||
},
|
||||
generatedAt: '2026-06-29T08:00:00.000Z',
|
||||
};
|
||||
|
||||
test('findCachedInsight returns matching signature only', () => {
|
||||
const rows: XiaobaoRiskInsightCacheItem[] = [
|
||||
insightA,
|
||||
{ ...insightA, versionId: 'v-2', riskSignature: 'sig-a' },
|
||||
{ ...insightA, versionId: 'v-1', riskSignature: 'sig-b' },
|
||||
];
|
||||
|
||||
assert.equal(findCachedInsight(rows, 'v-1', 'sig-a'), insightA);
|
||||
assert.equal(findCachedInsight(rows, 'v-1', 'sig-b')?.versionId, 'v-1');
|
||||
assert.equal(findCachedInsight(rows, 'v-2', 'sig-b'), undefined);
|
||||
});
|
||||
|
||||
test('upsertDailySnapshot keeps one snapshot per version and date', () => {
|
||||
const existing: XiaobaoRiskSnapshot = {
|
||||
versionId: 'v-1',
|
||||
date: '2026-06-29',
|
||||
riskScore: 40,
|
||||
riskLevel: 'attention',
|
||||
openBugCount: 1,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-06-29T08:00:00.000Z',
|
||||
};
|
||||
const replacement: XiaobaoRiskSnapshot = { ...existing, riskScore: 72, createdAt: '2026-06-29T09:00:00.000Z' };
|
||||
const otherDay: XiaobaoRiskSnapshot = { ...existing, date: '2026-06-28', createdAt: '2026-06-28T09:00:00.000Z' };
|
||||
|
||||
const result = upsertDailySnapshot([existing, otherDay], replacement);
|
||||
|
||||
assert.deepEqual(result, [replacement, otherDay]);
|
||||
});
|
||||
|
||||
test('upsertInsight replaces existing version signature pair', () => {
|
||||
const replacement: XiaobaoRiskInsightCacheItem = {
|
||||
...insightA,
|
||||
insight: { ...insightA.insight, summary: '已重新生成' },
|
||||
generatedAt: '2026-06-29T09:00:00.000Z',
|
||||
};
|
||||
const otherSignature: XiaobaoRiskInsightCacheItem = { ...insightA, riskSignature: 'sig-b' };
|
||||
const otherVersion: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'v-2' };
|
||||
|
||||
const result = upsertInsight([insightA, otherSignature, otherVersion], replacement);
|
||||
|
||||
assert.deepEqual(result, [replacement, otherSignature, otherVersion]);
|
||||
});
|
||||
|
||||
test('mergeDailySnapshotCacheForSave preserves remote rows and local-only rows', () => {
|
||||
const remoteOnly: XiaobaoRiskSnapshot = {
|
||||
versionId: 'remote-version',
|
||||
date: '2026-06-29',
|
||||
riskScore: 30,
|
||||
riskLevel: 'attention',
|
||||
openBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 90,
|
||||
createdAt: '2026-06-29T08:00:00.000Z',
|
||||
};
|
||||
const localOnly: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'local-version', riskScore: 50 };
|
||||
const item: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'current-version', riskScore: 70 };
|
||||
|
||||
const result = mergeDailySnapshotCacheForSave([localOnly], [remoteOnly], item);
|
||||
|
||||
assert.deepEqual(result, [item, remoteOnly, localOnly]);
|
||||
});
|
||||
|
||||
test('mergeInsightCacheForSave preserves remote rows and local-only rows', () => {
|
||||
const remoteOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'remote-version' };
|
||||
const localOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'local-version' };
|
||||
const item: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'current-version' };
|
||||
|
||||
const result = mergeInsightCacheForSave([localOnly], [remoteOnly], item);
|
||||
|
||||
assert.deepEqual(result, [item, remoteOnly, localOnly]);
|
||||
});
|
||||
67
apps/web/lib/xiaobao-risk-cache.ts
Normal file
67
apps/web/lib/xiaobao-risk-cache.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
|
||||
export interface XiaobaoRiskInsight {
|
||||
summary: string;
|
||||
why: string[];
|
||||
forecast: string;
|
||||
recommendedReleaseWindow?: string;
|
||||
suggestedActions: string[];
|
||||
ownerHints: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface XiaobaoRiskInsightCacheItem {
|
||||
versionId: string;
|
||||
riskSignature: string;
|
||||
insight: XiaobaoRiskInsight;
|
||||
generatedAt: string;
|
||||
providerInfo?: { providerId?: string; model?: string };
|
||||
}
|
||||
|
||||
export function findCachedInsight(
|
||||
rows: XiaobaoRiskInsightCacheItem[],
|
||||
versionId: string,
|
||||
riskSignature: string,
|
||||
): XiaobaoRiskInsightCacheItem | undefined {
|
||||
return rows.find((row) => row.versionId === versionId && row.riskSignature === riskSignature);
|
||||
}
|
||||
|
||||
export function upsertInsight(
|
||||
rows: XiaobaoRiskInsightCacheItem[],
|
||||
item: XiaobaoRiskInsightCacheItem,
|
||||
): XiaobaoRiskInsightCacheItem[] {
|
||||
return [
|
||||
item,
|
||||
...rows.filter((row) => row.versionId !== item.versionId || row.riskSignature !== item.riskSignature),
|
||||
];
|
||||
}
|
||||
|
||||
export function upsertDailySnapshot(
|
||||
rows: XiaobaoRiskSnapshot[],
|
||||
item: XiaobaoRiskSnapshot,
|
||||
): XiaobaoRiskSnapshot[] {
|
||||
return [
|
||||
item,
|
||||
...rows.filter((row) => row.versionId !== item.versionId || row.date !== item.date),
|
||||
];
|
||||
}
|
||||
|
||||
export function mergeDailySnapshotCacheForSave(
|
||||
localRows: XiaobaoRiskSnapshot[],
|
||||
remoteRows: XiaobaoRiskSnapshot[],
|
||||
item: XiaobaoRiskSnapshot,
|
||||
): XiaobaoRiskSnapshot[] {
|
||||
const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.date}`));
|
||||
const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.date}`));
|
||||
return upsertDailySnapshot([...remoteRows, ...localOnly], item);
|
||||
}
|
||||
|
||||
export function mergeInsightCacheForSave(
|
||||
localRows: XiaobaoRiskInsightCacheItem[],
|
||||
remoteRows: XiaobaoRiskInsightCacheItem[],
|
||||
item: XiaobaoRiskInsightCacheItem,
|
||||
): XiaobaoRiskInsightCacheItem[] {
|
||||
const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.riskSignature}`));
|
||||
const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.riskSignature}`));
|
||||
return upsertInsight([...remoteRows, ...localOnly], item);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import type { Bug } from './bug';
|
||||
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
|
||||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot, summarizeRiskTrend } from './xiaobao-risk-trend';
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
|
||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
||||
@@ -41,6 +41,45 @@ test('buildRiskSignature changes when score and bug counts change', () => {
|
||||
assert.notEqual(base, changed);
|
||||
});
|
||||
|
||||
test('buildRiskSignature changes when critical bug count changes without open bug count change', () => {
|
||||
const base = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 0 }));
|
||||
const changed = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 1 }));
|
||||
|
||||
assert.notEqual(base, changed);
|
||||
});
|
||||
|
||||
test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => {
|
||||
const latest = findLatestDailySnapshot([
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 45, createdAt: '2026-06-29T10:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 70, createdAt: '2026-06-28T10:00:00.000Z' }),
|
||||
snapshot({ versionId: 'ver-2', date: '2026-06-29', riskScore: 90, createdAt: '2026-06-29T11:00:00.000Z' }),
|
||||
], 'ver-1', '2026-06-29');
|
||||
|
||||
assert.equal(latest?.riskScore, 45);
|
||||
});
|
||||
|
||||
test('shouldSaveRiskSnapshot skips small same-day changes inside throttle window', () => {
|
||||
const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||
const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:03:00.000Z' });
|
||||
|
||||
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:03:00.000Z')), false);
|
||||
});
|
||||
|
||||
test('shouldSaveRiskSnapshot saves material signal changes immediately', () => {
|
||||
const previous = snapshot({ date: '2026-06-29', riskScore: 40, criticalBugCount: 0, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||
const current = snapshot({ date: '2026-06-29', riskScore: 41, criticalBugCount: 1, createdAt: '2026-06-29T10:02:00.000Z' });
|
||||
|
||||
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:02:00.000Z')), true);
|
||||
});
|
||||
|
||||
test('shouldSaveRiskSnapshot saves minor signature changes after throttle window', () => {
|
||||
const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' });
|
||||
const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:12:00.000Z' });
|
||||
|
||||
assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:12:00.000Z')), true);
|
||||
});
|
||||
|
||||
test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
|
||||
const now = new Date('2026-07-02T01:00:00.000Z');
|
||||
const risk = calcXiaobaoVersionRisk({
|
||||
@@ -59,19 +98,10 @@ test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot si
|
||||
|
||||
assert.equal(risk.signals.openBugCount, 1);
|
||||
assert.equal(risk.signals.criticalBugCount, 0);
|
||||
assert.equal(currentSignature, buildRiskSignature({
|
||||
versionId: risk.versionId,
|
||||
date: now.toISOString().slice(0, 10),
|
||||
riskScore: risk.riskScore,
|
||||
riskLevel: risk.riskLevel,
|
||||
forecastReleaseDate: risk.forecastReleaseDate,
|
||||
openBugCount: 1,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: risk.confidence,
|
||||
createdAt: now.toISOString(),
|
||||
}));
|
||||
assert.equal(risk.currentSnapshot.openBugCount, 1);
|
||||
assert.equal(risk.currentSnapshot.criticalBugCount, 0);
|
||||
assert.equal(currentSignature, buildRiskSignature(risk.currentSnapshot));
|
||||
assert.equal(buildRiskSignature(risk.currentSnapshot).split('|')[5], '1');
|
||||
});
|
||||
|
||||
function bug(patch: Partial<Bug> = {}): Bug {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot {
|
||||
riskLevel: XiaobaoRiskLevel;
|
||||
forecastReleaseDate?: string;
|
||||
openBugCount: number;
|
||||
criticalBugCount?: number;
|
||||
failedTestCount: number;
|
||||
blockedCount: number;
|
||||
silentRiskCount: number;
|
||||
@@ -22,6 +23,10 @@ export interface RiskTrendSummary {
|
||||
currentSignature?: string;
|
||||
}
|
||||
|
||||
const SNAPSHOT_SAVE_THROTTLE_MS = 10 * 60 * 1000;
|
||||
const SNAPSHOT_SCORE_DELTA = 5;
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
|
||||
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
|
||||
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
||||
if (sorted.length < 2) {
|
||||
@@ -80,6 +85,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
||||
snapshot.riskLevel,
|
||||
snapshot.forecastReleaseDate ?? '',
|
||||
snapshot.openBugCount,
|
||||
snapshot.criticalBugCount ?? 0,
|
||||
snapshot.failedTestCount,
|
||||
snapshot.blockedCount,
|
||||
snapshot.silentRiskCount,
|
||||
@@ -87,6 +93,49 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function findLatestDailySnapshot(
|
||||
snapshots: XiaobaoRiskSnapshot[],
|
||||
versionId: string,
|
||||
date: string,
|
||||
): XiaobaoRiskSnapshot | undefined {
|
||||
return snapshots
|
||||
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date === date)
|
||||
.sort((a, b) => getSnapshotTime(b) - getSnapshotTime(a))[0];
|
||||
}
|
||||
|
||||
export function shouldSaveRiskSnapshot(
|
||||
current: XiaobaoRiskSnapshot,
|
||||
previous?: XiaobaoRiskSnapshot,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!previous) return true;
|
||||
if (previous.versionId !== current.versionId || previous.date !== current.date) return true;
|
||||
if (buildRiskSignature(current) === buildRiskSignature(previous)) return false;
|
||||
if (hasMaterialSnapshotChange(current, previous)) return true;
|
||||
|
||||
const previousTime = getSnapshotTime(previous);
|
||||
const nowTime = now.getTime();
|
||||
if (!Number.isFinite(previousTime) || !Number.isFinite(nowTime)) return true;
|
||||
return nowTime - previousTime >= SNAPSHOT_SAVE_THROTTLE_MS;
|
||||
}
|
||||
|
||||
function hasMaterialSnapshotChange(current: XiaobaoRiskSnapshot, previous: XiaobaoRiskSnapshot): boolean {
|
||||
if (current.riskLevel !== previous.riskLevel) return true;
|
||||
if (Math.abs(clampScore(current.riskScore) - clampScore(previous.riskScore)) >= SNAPSHOT_SCORE_DELTA) return true;
|
||||
if ((current.criticalBugCount ?? 0) !== (previous.criticalBugCount ?? 0)) return true;
|
||||
if (current.failedTestCount !== previous.failedTestCount) return true;
|
||||
if (current.blockedCount !== previous.blockedCount) return true;
|
||||
if (current.silentRiskCount !== previous.silentRiskCount) return true;
|
||||
return hasForecastShiftedByOneDay(current.forecastReleaseDate, previous.forecastReleaseDate);
|
||||
}
|
||||
|
||||
function hasForecastShiftedByOneDay(current?: string, previous?: string): boolean {
|
||||
if (!current && !previous) return false;
|
||||
if (!current || !previous) return true;
|
||||
const delta = Math.abs(new Date(current).getTime() - new Date(previous).getTime());
|
||||
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
|
||||
}
|
||||
|
||||
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
||||
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
||||
return Number.isFinite(date) ? date : 0;
|
||||
|
||||
@@ -180,3 +180,17 @@ test('calcXiaobaoVersionRisk preserves null expected release date as a compatibl
|
||||
|
||||
assert.equal(risk.expectedReleaseDate, null);
|
||||
});
|
||||
|
||||
test('calcXiaobaoVersionRisk preserves version display context for AI interpretation', () => {
|
||||
const risk = calcXiaobaoVersionRisk({
|
||||
version: version({ name: 'V2.0', productName: 'FTB', projectName: 'Project PM' }),
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
now: new Date('2026-07-02T01:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(risk.versionName, 'V2.0');
|
||||
assert.equal(risk.productName, 'FTB');
|
||||
assert.equal(risk.projectName, 'Project PM');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DevTask } from './dev-task';
|
||||
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import { getTestCaseEstimateHours } from './test-case';
|
||||
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
|
||||
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
import { WORK_HOURS, addWorkHours } from './work-hours';
|
||||
@@ -14,7 +15,9 @@ export interface XiaobaoVersionRef {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
productId?: string;
|
||||
productName?: string;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
expectedReleaseDate?: string | null;
|
||||
members?: Array<{ id?: string; name: string; role?: string }>;
|
||||
@@ -48,6 +51,11 @@ export interface XiaobaoRiskSignals {
|
||||
|
||||
export interface XiaobaoVersionRisk {
|
||||
versionId: string;
|
||||
versionName: string;
|
||||
productId?: string;
|
||||
productName?: string;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
riskScore: number;
|
||||
riskLevel: XiaobaoRiskLevel;
|
||||
expectedReleaseDate: string | null;
|
||||
@@ -60,10 +68,13 @@ export interface XiaobaoVersionRisk {
|
||||
silentRisks: SilentRisk[];
|
||||
dailyEvidence?: VersionDailyEvidence;
|
||||
signals: XiaobaoRiskSignals;
|
||||
currentSnapshot: XiaobaoRiskSnapshot;
|
||||
aiInsight?: XiaobaoRiskInsight;
|
||||
trend: {
|
||||
direction: 'up' | 'down' | 'flat' | 'unknown';
|
||||
delta: number;
|
||||
summary: string;
|
||||
pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,22 +209,29 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
||||
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
|
||||
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
|
||||
const confidence = calcConfidence(input, devTasks, testCases, bugs);
|
||||
const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], {
|
||||
const currentSnapshot: XiaobaoRiskSnapshot = {
|
||||
versionId: input.version.id,
|
||||
date: now.toISOString().slice(0, 10),
|
||||
riskScore,
|
||||
riskLevel,
|
||||
forecastReleaseDate,
|
||||
openBugCount: signals.openBugCount,
|
||||
criticalBugCount: signals.criticalBugCount,
|
||||
failedTestCount: signals.failedTestCount,
|
||||
blockedCount: signals.blockedCount,
|
||||
silentRiskCount: signals.silentRiskCount,
|
||||
confidence,
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
};
|
||||
const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], currentSnapshot);
|
||||
|
||||
return {
|
||||
versionId: input.version.id,
|
||||
versionName: input.version.name,
|
||||
productId: input.version.productId,
|
||||
productName: input.version.productName,
|
||||
projectId: input.version.projectId,
|
||||
projectName: input.version.projectName,
|
||||
riskScore,
|
||||
riskLevel,
|
||||
expectedReleaseDate: input.version.expectedReleaseDate ?? null,
|
||||
@@ -226,6 +244,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
||||
silentRisks,
|
||||
dailyEvidence: input.dailyEvidence,
|
||||
signals,
|
||||
currentSnapshot,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
152
apps/web/lib/xiaobao-warning-view.test.ts
Normal file
152
apps/web/lib/xiaobao-warning-view.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { VersionWithContext } from './derive';
|
||||
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import {
|
||||
filterXiaobaoRiskWarnings,
|
||||
filterXiaobaoWarningVersions,
|
||||
formatRemainingWork,
|
||||
getXiaobaoWarningRiskCount,
|
||||
sanitizeRiskInsight,
|
||||
} from './xiaobao-warning-view';
|
||||
|
||||
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
||||
return {
|
||||
id: 'ver-1',
|
||||
name: 'V1.0',
|
||||
status: 'developing',
|
||||
releaseDate: null,
|
||||
createdAt: '2026-06-29T00:00:00.000Z',
|
||||
productId: 'prod-1',
|
||||
productName: 'FTB',
|
||||
projectId: 'proj-1',
|
||||
projectName: 'Project',
|
||||
expectedReleaseDate: '2026-07-05T10:00:00.000Z',
|
||||
members: [{ name: 'Alice', role: 'frontend' }],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||
return {
|
||||
versionId: 'ver-1',
|
||||
versionName: 'V1.0',
|
||||
productId: 'prod-1',
|
||||
productName: 'Product A',
|
||||
projectId: 'proj-1',
|
||||
projectName: 'Project A',
|
||||
riskScore: 68,
|
||||
riskLevel: 'attention',
|
||||
expectedReleaseDate: '2026-07-05',
|
||||
confidence: 80,
|
||||
confidenceLevel: 'high',
|
||||
delayDays: 0,
|
||||
remainingWorkHours: 8,
|
||||
reasons: [],
|
||||
silentRisks: [],
|
||||
signals: {
|
||||
unfinishedCount: 1,
|
||||
openBugCount: 0,
|
||||
criticalBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
},
|
||||
currentSnapshot: {
|
||||
versionId: 'ver-1',
|
||||
date: '2026-06-30',
|
||||
riskScore: 68,
|
||||
riskLevel: 'attention',
|
||||
openBugCount: 0,
|
||||
criticalBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-06-30T10:00:00.000Z',
|
||||
},
|
||||
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
|
||||
...patch,
|
||||
} as XiaobaoVersionRisk;
|
||||
}
|
||||
|
||||
test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => {
|
||||
const result = filterXiaobaoWarningVersions(
|
||||
[
|
||||
version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-2', status: 'planned', members: [{ name: 'Bob', role: 'testing' }] }),
|
||||
version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
],
|
||||
{ canManage: true, userName: 'Alice' },
|
||||
);
|
||||
|
||||
assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']);
|
||||
});
|
||||
|
||||
test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => {
|
||||
const result = filterXiaobaoWarningVersions(
|
||||
[
|
||||
version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-2', status: 'developing', members: [{ name: 'Bob', role: 'testing' }] }),
|
||||
version({ id: 'ver-3', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
],
|
||||
{ canManage: false, userName: 'Alice' },
|
||||
);
|
||||
|
||||
assert.deepEqual(result.map((item) => item.id), ['ver-1']);
|
||||
});
|
||||
|
||||
test('filterXiaobaoRiskWarnings hides on_track risks and badge count follows visible risks', () => {
|
||||
const risks = [
|
||||
risk({ versionId: 'ver-1', riskLevel: 'on_track', riskScore: 10 }),
|
||||
risk({ versionId: 'ver-2', riskLevel: 'attention', riskScore: 38 }),
|
||||
risk({ versionId: 'ver-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterXiaobaoRiskWarnings(risks).map((item) => item.versionId), ['ver-2', 'ver-3']);
|
||||
assert.equal(getXiaobaoWarningRiskCount(risks), 2);
|
||||
});
|
||||
|
||||
test('filterXiaobaoRiskWarnings filters by product project and risk tier', () => {
|
||||
const risks = [
|
||||
risk({ versionId: 'ver-1', productId: 'prod-1', projectId: 'proj-1', riskLevel: 'attention', riskScore: 38 }),
|
||||
risk({ versionId: 'ver-2', productId: 'prod-1', projectId: 'proj-2', riskLevel: 'likely_delayed', riskScore: 88 }),
|
||||
risk({ versionId: 'ver-3', productId: 'prod-2', projectId: 'proj-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1' }).map((item) => item.versionId),
|
||||
['ver-1', 'ver-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1', projectId: 'proj-2' }).map((item) => item.versionId),
|
||||
['ver-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterXiaobaoRiskWarnings(risks, { riskFilter: 'high' }).map((item) => item.versionId),
|
||||
['ver-2', 'ver-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('formatRemainingWork keeps hours and adds work-day conversion', () => {
|
||||
assert.equal(formatRemainingWork(0), '0h / 0天');
|
||||
assert.equal(formatRemainingWork(8), '8h / 1天');
|
||||
assert.equal(formatRemainingWork(12), '12h / 1.5天');
|
||||
assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天');
|
||||
});
|
||||
|
||||
test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => {
|
||||
const result = sanitizeRiskInsight({
|
||||
summary: '风险上升',
|
||||
why: ['P1 Bug 增加'],
|
||||
forecast: '预计延期 1 天',
|
||||
suggestedActions: [
|
||||
'手动触发页面刷新,等待小宝预警自动更新',
|
||||
'优先处理 3 个 P1 Bug,并同步测试负责人复测',
|
||||
],
|
||||
ownerHints: ['研发负责人协调修复顺序'],
|
||||
generatedAt: '2026-06-30T10:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']);
|
||||
});
|
||||
80
apps/web/lib/xiaobao-warning-view.ts
Normal file
80
apps/web/lib/xiaobao-warning-view.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { VersionWithContext } from './derive';
|
||||
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import { WORK_HOURS } from './work-hours';
|
||||
|
||||
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
||||
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
||||
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
||||
/(手动|主动).*(触发|刷新).*(更新|预警|分析)/i,
|
||||
/(manual|manually).*(refresh|reload|trigger)/i,
|
||||
/(refresh|reload).*(page|browser|xiaobao|warning)/i,
|
||||
];
|
||||
|
||||
export interface XiaobaoWarningVersionFilter {
|
||||
canManage: boolean;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export type XiaobaoWarningRiskFilter = 'all' | 'attention' | 'high';
|
||||
|
||||
export interface XiaobaoWarningRiskListFilter {
|
||||
riskFilter?: XiaobaoWarningRiskFilter;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function filterXiaobaoWarningVersions(
|
||||
versions: VersionWithContext[],
|
||||
filter: XiaobaoWarningVersionFilter,
|
||||
): VersionWithContext[] {
|
||||
return versions.filter((version) => {
|
||||
if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false;
|
||||
if (filter.canManage) return true;
|
||||
if (!filter.userName) return false;
|
||||
return (version.members ?? []).some((member) => member.name === filter.userName);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterXiaobaoRiskWarnings(
|
||||
risks: XiaobaoVersionRisk[],
|
||||
filter: XiaobaoWarningRiskListFilter = {},
|
||||
): XiaobaoVersionRisk[] {
|
||||
return risks.filter((risk) => {
|
||||
if (risk.riskLevel === 'on_track') return false;
|
||||
if (filter.productId && risk.productId !== filter.productId) return false;
|
||||
if (filter.projectId && risk.projectId !== filter.projectId) return false;
|
||||
if (filter.riskFilter === 'attention') return true;
|
||||
if (filter.riskFilter === 'high') return HIGH_RISK_LEVELS.has(risk.riskLevel);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number {
|
||||
return filterXiaobaoRiskWarnings(risks).length;
|
||||
}
|
||||
|
||||
export function formatRemainingWork(hours: number): string {
|
||||
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
|
||||
const days = safeHours / WORK_HOURS.hoursPerDay;
|
||||
return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`;
|
||||
}
|
||||
|
||||
export function sanitizeRiskInsight(insight: XiaobaoRiskInsight): XiaobaoRiskInsight {
|
||||
return {
|
||||
...insight,
|
||||
suggestedActions: insight.suggestedActions.filter((action) => !isPageRefreshAdvice(action)),
|
||||
ownerHints: insight.ownerHints.filter((hint) => !isPageRefreshAdvice(hint)),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
return value.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
function isPageRefreshAdvice(text: string): boolean {
|
||||
return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
Reference in New Issue
Block a user