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