diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts new file mode 100644 index 0000000..288fa56 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -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 { + 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 { + 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), + ), + 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.'); +}); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts new file mode 100644 index 0000000..b5f1c6e --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -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 { + return api.postRaw( + '/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(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))); +} diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts index 2424c11..d1459ea 100644 --- a/apps/web/lib/xiaobao-risk-trend.test.ts +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -41,6 +41,13 @@ 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('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => { const now = new Date('2026-07-02T01:00:00.000Z'); const risk = calcXiaobaoVersionRisk({ diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts index c3249e1..0e88ff0 100644 --- a/apps/web/lib/xiaobao-risk-trend.ts +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot { riskLevel: XiaobaoRiskLevel; forecastReleaseDate?: string; openBugCount: number; + criticalBugCount?: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; @@ -76,6 +77,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { snapshot.riskLevel, snapshot.forecastReleaseDate ?? '', snapshot.openBugCount, + snapshot.criticalBugCount ?? 0, snapshot.failedTestCount, snapshot.blockedCount, snapshot.silentRiskCount, diff --git a/apps/web/lib/xiaobao-risk.test.ts b/apps/web/lib/xiaobao-risk.test.ts index 51f4e38..9a2372e 100644 --- a/apps/web/lib/xiaobao-risk.test.ts +++ b/apps/web/lib/xiaobao-risk.test.ts @@ -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'); +}); diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 8197766..64adc1f 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -49,6 +49,9 @@ export interface XiaobaoRiskSignals { export interface XiaobaoVersionRisk { versionId: string; + versionName: string; + productName?: string; + projectName?: string; riskScore: number; riskLevel: XiaobaoRiskLevel; expectedReleaseDate: string | null; @@ -67,6 +70,7 @@ export interface XiaobaoVersionRisk { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; summary: string; + pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; }; } @@ -208,6 +212,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao riskLevel, forecastReleaseDate, openBugCount: signals.openBugCount, + criticalBugCount: signals.criticalBugCount, failedTestCount: signals.failedTestCount, blockedCount: signals.blockedCount, silentRiskCount: signals.silentRiskCount, @@ -218,6 +223,9 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao return { versionId: input.version.id, + versionName: input.version.name, + productName: input.version.productName, + projectName: input.version.projectName, riskScore, riskLevel, expectedReleaseDate: input.version.expectedReleaseDate ?? null,