feat(小宝预警): 增加 AI 解读触发策略

This commit is contained in:
Script Generator
2026-06-29 19:32:26 +08:00
parent 064617aba6
commit 94a038494a
6 changed files with 487 additions and 0 deletions

View 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.');
});

View 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)));
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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');
});

View File

@@ -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,