fix(小宝预警): 增加快照节流和 AI cooldown
This commit is contained in:
@@ -18,9 +18,9 @@ import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
|||||||
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
|
import { buildRiskInsightSignature, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai';
|
||||||
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
import { buildRiskSignature } from '@/lib/xiaobao-risk-trend';
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
type RiskFilter = 'all' | 'attention' | 'high';
|
type RiskFilter = 'all' | 'attention' | 'high';
|
||||||
@@ -128,7 +128,9 @@ function XiaobaoWarningContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
risks.forEach((risk) => {
|
risks.forEach((risk) => {
|
||||||
const snapshot = risk.currentSnapshot;
|
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
||||||
|
const previousToday = findLatestDailySnapshot(snapshots, snapshot.versionId, snapshot.date);
|
||||||
|
if (!shouldSaveRiskSnapshot(snapshot, previousToday)) return;
|
||||||
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
|
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
|
||||||
if (savedSnapshotKeysRef.current.has(key)) return;
|
if (savedSnapshotKeysRef.current.has(key)) return;
|
||||||
savedSnapshotKeysRef.current.add(key);
|
savedSnapshotKeysRef.current.add(key);
|
||||||
@@ -136,13 +138,14 @@ function XiaobaoWarningContent() {
|
|||||||
savedSnapshotKeysRef.current.delete(key);
|
savedSnapshotKeysRef.current.delete(key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [risks, saveSnapshot]);
|
}, [risks, saveSnapshot, snapshots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
risks.forEach((risk) => {
|
risks.forEach((risk) => {
|
||||||
if (getReusableInsight(insights, risk)) return;
|
if (getReusableInsight(insights, risk)) return;
|
||||||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||||
if (!shouldRequestRiskInsight(risk, previous)) return;
|
const latestInsight = findLatestRiskInsightForVersion(insights, risk.versionId);
|
||||||
|
if (!shouldRequestRiskInsightWithCooldown(risk, previous, latestInsight)) return;
|
||||||
const signature = buildRiskInsightSignature(risk);
|
const signature = buildRiskInsightSignature(risk);
|
||||||
const key = `${risk.versionId}:${signature}`;
|
const key = `${risk.versionId}:${signature}`;
|
||||||
if (requestedInsightKeysRef.current.has(key)) return;
|
if (requestedInsightKeysRef.current.has(key)) return;
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
|||||||
import {
|
import {
|
||||||
buildRiskInsightSignature,
|
buildRiskInsightSignature,
|
||||||
buildRiskInterpretRequest,
|
buildRiskInterpretRequest,
|
||||||
|
findLatestRiskInsightForVersion,
|
||||||
findPreviousRiskSnapshot,
|
findPreviousRiskSnapshot,
|
||||||
shouldRequestRiskInsight,
|
shouldRequestRiskInsight,
|
||||||
|
shouldRequestRiskInsightWithCooldown,
|
||||||
} from './xiaobao-risk-ai';
|
} from './xiaobao-risk-ai';
|
||||||
|
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
|
|
||||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
|
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -69,6 +72,23 @@ function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
|||||||
} as unknown as XiaobaoVersionRisk;
|
} 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', () => {
|
test('shouldRequestRiskInsight skips on_track', () => {
|
||||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
|
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
|
||||||
});
|
});
|
||||||
@@ -184,6 +204,45 @@ test('shouldRequestRiskInsight triggers high risk levels', () => {
|
|||||||
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), 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('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
||||||
const base = buildRiskInsightSignature(risk({
|
const base = buildRiskInsightSignature(risk({
|
||||||
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ type RiskInsightPrevious = Pick<
|
|||||||
const SCORE_TRIGGER_DELTA = 15;
|
const SCORE_TRIGGER_DELTA = 15;
|
||||||
const CONFIDENCE_DROP_DELTA = 15;
|
const CONFIDENCE_DROP_DELTA = 15;
|
||||||
const ONE_DAY_MS = 86_400_000;
|
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 {
|
export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
|
||||||
if (current.riskLevel === 'on_track') return false;
|
if (current.riskLevel === 'on_track') return false;
|
||||||
@@ -57,6 +65,31 @@ export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
versionId: risk.versionId,
|
versionId: risk.versionId,
|
||||||
@@ -219,3 +252,26 @@ function compareSignatureRows<T>(a: T, b: T): number {
|
|||||||
function clampScore(score: number): number {
|
function clampScore(score: number): number {
|
||||||
return Math.max(0, Math.min(100, Math.round(score)));
|
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 getTime(value: string): number {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : Number.NaN;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import type { Bug } from './bug';
|
import type { Bug } from './bug';
|
||||||
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
|
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';
|
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||||
|
|
||||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
||||||
@@ -48,6 +48,38 @@ test('buildRiskSignature changes when critical bug count changes without open bu
|
|||||||
assert.notEqual(base, changed);
|
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', () => {
|
test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
|
||||||
const now = new Date('2026-07-02T01:00:00.000Z');
|
const now = new Date('2026-07-02T01:00:00.000Z');
|
||||||
const risk = calcXiaobaoVersionRisk({
|
const risk = calcXiaobaoVersionRisk({
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface RiskTrendSummary {
|
|||||||
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
|
||||||
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
||||||
if (sorted.length < 2) {
|
if (sorted.length < 2) {
|
||||||
@@ -85,6 +89,49 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
|||||||
].join('|');
|
].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 {
|
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
||||||
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
||||||
return Number.isFinite(date) ? date : 0;
|
return Number.isFinite(date) ? date : 0;
|
||||||
|
|||||||
@@ -413,6 +413,8 @@
|
|||||||
- 小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势、静默风险和证据摘要。
|
- 小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势、静默风险和证据摘要。
|
||||||
- `on_track` 不触发 AI;`at_risk`、`likely_delayed`、`blocked` 自动触发 AI;`attention` 只有在风险分、趋势、关键 Bug、失败用例、阻塞、静默风险、置信度或预测日期出现明显恶化时触发。
|
- `on_track` 不触发 AI;`at_risk`、`likely_delayed`、`blocked` 自动触发 AI;`attention` 只有在风险分、趋势、关键 Bug、失败用例、阻塞、静默风险、置信度或预测日期出现明显恶化时触发。
|
||||||
- AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。
|
- AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。
|
||||||
|
- 快照保存需要节流:同版本同日普通变化 10 分钟内不重复保存;风险等级变化、风险分变化达到阈值、关键 Bug/失败用例/阻塞/静默风险变化或预测日期明显变化时立即保存。
|
||||||
|
- AI 解读需要 cooldown:同版本最近 6 小时内已有解读时不重复请求;如果风险等级升级,则允许绕过 cooldown。
|
||||||
- AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。
|
- AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。
|
||||||
|
|
||||||
**理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。
|
**理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。
|
||||||
|
|||||||
@@ -233,9 +233,9 @@ Implementation convention:
|
|||||||
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
||||||
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
||||||
|
|
||||||
页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。
|
页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。快照按同版本同日节流保存:重大变化立即保存,普通变化 10 分钟内不重复写入。
|
||||||
|
|
||||||
AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。
|
AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。
|
||||||
|
|
||||||
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user