Files
ftb-project-management/apps/web/lib/xiaobao-risk-ai.ts
2026-06-30 10:03:12 +08:00

332 lines
12 KiB
TypeScript

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