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 = { 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), todayCreations: normalizeEvidence(risk.dailyEvidence?.todayCreations), todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks), progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes), needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems), recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0, todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0, lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null, }, }); } export function buildRiskInsightDisplaySignature(risk: XiaobaoVersionRisk): string { return normalizeRiskInsightDisplaySignature(buildRiskInsightSignature(risk)) ?? buildRiskInsightSignature(risk); } export function normalizeRiskInsightDisplaySignature(signature: string): string | undefined { return buildDisplayCompatibilityKey(signature); } 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) ?? findLatestDisplayCompatibleInsight(cache, risk.versionId, 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), todayCreations: summarizeEvidence(risk.dailyEvidence?.todayCreations), todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks), progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes), needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems), recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0, todayActualHours: risk.dailyEvidence?.todayActualHours ?? 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))); } 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 findLatestDisplayCompatibleInsight( cache: XiaobaoRiskInsightCacheItem[], versionId: string, currentSignature: string, ): XiaobaoRiskInsightCacheItem | undefined { const currentKey = buildDisplayCompatibilityKey(currentSignature); if (!currentKey) return undefined; return cache .filter((item) => item.versionId === versionId && buildDisplayCompatibilityKey(item.riskSignature) === currentKey) .sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0]; } function buildDisplayCompatibilityKey(signature: string): string | undefined { try { const parsed = JSON.parse(signature) as Record; const signals = readRecord(parsed.signals); const trend = readRecord(parsed.trend); const dailyEvidence = readRecord(parsed.dailyEvidence); return JSON.stringify({ versionId: readString(parsed.versionId), riskScore: normalizeInteger(parsed.riskScore), riskLevel: readString(parsed.riskLevel), expectedReleaseDate: normalizeDateKey(readString(parsed.expectedReleaseDate)), delayDays: normalizeInteger(parsed.delayDays), confidence: normalizeInteger(parsed.confidence), signals: { unfinishedCount: normalizeInteger(signals.unfinishedCount), openBugCount: normalizeInteger(signals.openBugCount), criticalBugCount: normalizeInteger(signals.criticalBugCount), failedTestCount: normalizeInteger(signals.failedTestCount), blockedCount: normalizeInteger(signals.blockedCount), silentRiskCount: normalizeInteger(signals.silentRiskCount), }, trend: { direction: readString(trend.direction), delta: normalizeInteger(trend.delta), pattern: readString(trend.pattern), }, reasons: normalizeCompatibilityReasons(parsed.reasons), silentRisks: normalizeCompatibilitySilentRisks(parsed.silentRisks), dailyEvidence: { todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries), todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress), todayCreations: normalizeCompatibilityEvidence(dailyEvidence.todayCreations), todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks), progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes), needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems), totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount), }, }); } catch { return undefined; } } function normalizeCompatibilityReasons(rows: unknown) { return asRecordArray(rows) .map((row) => ({ key: readString(row.key), severity: readString(row.severity), detail: readString(row.detail), count: normalizeInteger(row.count), })) .sort(compareSignatureRows); } function normalizeCompatibilitySilentRisks(rows: unknown) { return asRecordArray(rows) .map((row) => ({ key: readString(row.key), itemId: readString(row.itemId), itemType: readString(row.itemType), })) .sort(compareSignatureRows); } function normalizeCompatibilityEvidence(rows: unknown) { return asRecordArray(rows) .map((row) => ({ id: readString(row.id), title: readString(row.title), summary: readString(row.summary), })) .sort(compareSignatureRows); } function asRecordArray(rows: unknown): Array> { return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : []; } function readRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function readString(value: unknown): string | null { return typeof value === 'string' ? value : null; } 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 normalizeInteger(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null; } function getTime(value: string): number { const time = new Date(value).getTime(); return Number.isFinite(time) ? time : Number.NaN; }