feat(小宝预警): 增加 AI 解读触发策略
This commit is contained in:
245
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
245
apps/web/lib/xiaobao-risk-ai.test.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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,
|
||||||
|
shouldRequestRiskInsight,
|
||||||
|
} from './xiaobao-risk-ai';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('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('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.');
|
||||||
|
});
|
||||||
211
apps/web/lib/xiaobao-risk-ai.ts
Normal file
211
apps/web/lib/xiaobao-risk-ai.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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 buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
versionId: risk.versionId,
|
||||||
|
riskScore: clampScore(risk.riskScore),
|
||||||
|
riskLevel: risk.riskLevel,
|
||||||
|
expectedReleaseDate: risk.expectedReleaseDate ?? null,
|
||||||
|
forecastReleaseDate: risk.forecastReleaseDate ?? null,
|
||||||
|
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: risk.signals.daysToExpectedRelease ?? null,
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ test('buildRiskSignature changes when score and bug counts change', () => {
|
|||||||
assert.notEqual(base, changed);
|
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('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({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot {
|
|||||||
riskLevel: XiaobaoRiskLevel;
|
riskLevel: XiaobaoRiskLevel;
|
||||||
forecastReleaseDate?: string;
|
forecastReleaseDate?: string;
|
||||||
openBugCount: number;
|
openBugCount: number;
|
||||||
|
criticalBugCount?: number;
|
||||||
failedTestCount: number;
|
failedTestCount: number;
|
||||||
blockedCount: number;
|
blockedCount: number;
|
||||||
silentRiskCount: number;
|
silentRiskCount: number;
|
||||||
@@ -76,6 +77,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
|||||||
snapshot.riskLevel,
|
snapshot.riskLevel,
|
||||||
snapshot.forecastReleaseDate ?? '',
|
snapshot.forecastReleaseDate ?? '',
|
||||||
snapshot.openBugCount,
|
snapshot.openBugCount,
|
||||||
|
snapshot.criticalBugCount ?? 0,
|
||||||
snapshot.failedTestCount,
|
snapshot.failedTestCount,
|
||||||
snapshot.blockedCount,
|
snapshot.blockedCount,
|
||||||
snapshot.silentRiskCount,
|
snapshot.silentRiskCount,
|
||||||
|
|||||||
@@ -180,3 +180,17 @@ test('calcXiaobaoVersionRisk preserves null expected release date as a compatibl
|
|||||||
|
|
||||||
assert.equal(risk.expectedReleaseDate, null);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface XiaobaoRiskSignals {
|
|||||||
|
|
||||||
export interface XiaobaoVersionRisk {
|
export interface XiaobaoVersionRisk {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
|
versionName: string;
|
||||||
|
productName?: string;
|
||||||
|
projectName?: string;
|
||||||
riskScore: number;
|
riskScore: number;
|
||||||
riskLevel: XiaobaoRiskLevel;
|
riskLevel: XiaobaoRiskLevel;
|
||||||
expectedReleaseDate: string | null;
|
expectedReleaseDate: string | null;
|
||||||
@@ -67,6 +70,7 @@ export interface XiaobaoVersionRisk {
|
|||||||
direction: 'up' | 'down' | 'flat' | 'unknown';
|
direction: 'up' | 'down' | 'flat' | 'unknown';
|
||||||
delta: number;
|
delta: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +212,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
|||||||
riskLevel,
|
riskLevel,
|
||||||
forecastReleaseDate,
|
forecastReleaseDate,
|
||||||
openBugCount: signals.openBugCount,
|
openBugCount: signals.openBugCount,
|
||||||
|
criticalBugCount: signals.criticalBugCount,
|
||||||
failedTestCount: signals.failedTestCount,
|
failedTestCount: signals.failedTestCount,
|
||||||
blockedCount: signals.blockedCount,
|
blockedCount: signals.blockedCount,
|
||||||
silentRiskCount: signals.silentRiskCount,
|
silentRiskCount: signals.silentRiskCount,
|
||||||
@@ -218,6 +223,9 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
versionId: input.version.id,
|
versionId: input.version.id,
|
||||||
|
versionName: input.version.name,
|
||||||
|
productName: input.version.productName,
|
||||||
|
projectName: input.version.projectName,
|
||||||
riskScore,
|
riskScore,
|
||||||
riskLevel,
|
riskLevel,
|
||||||
expectedReleaseDate: input.version.expectedReleaseDate ?? null,
|
expectedReleaseDate: input.version.expectedReleaseDate ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user