import type { VersionWithContext } from './derive'; import type { V22XiaobaoWarningSummary } from './v22-api'; import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; import type { RiskReason, SilentRisk, XiaobaoConfidenceLevel, XiaobaoRiskLevel, XiaobaoRiskSignals, XiaobaoVersionRisk, } from './xiaobao-risk'; import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence'; import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; interface BuildXiaobaoRisksFromV22SummariesInput { summaries: V22XiaobaoWarningSummary[]; versions: VersionWithContext[]; snapshots?: XiaobaoRiskSnapshot[]; now?: Date; } export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed'; const RISK_LEVELS = new Set(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']); const REASON_SEVERITIES = new Set(['info', 'warning', 'danger']); const SILENT_RISK_ITEM_TYPES = new Set>(['dev_task', 'test_case', 'bug', 'version']); export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean { return state === 'empty' || state === 'failed'; } export function filterV22XiaobaoSummariesForVisibleVersions( summaries: V22XiaobaoWarningSummary[], versions: VersionWithContext[], ): V22XiaobaoWarningSummary[] { const visibleVersionIds = new Set(versions.map((version) => version.id)); if (visibleVersionIds.size === 0) return []; return summaries.filter((summary) => visibleVersionIds.has(summary.versionId)); } export function collectV22XiaobaoLatestInsights( summaries: V22XiaobaoWarningSummary[], ): XiaobaoRiskInsightCacheItem[] { return summaries .map((summary) => summary.latestInsight) .filter((item): item is XiaobaoRiskInsightCacheItem => Boolean(item)); } export function mergeV22XiaobaoLatestInsights( relationInsights: XiaobaoRiskInsightCacheItem[], legacyInsights: XiaobaoRiskInsightCacheItem[], ): XiaobaoRiskInsightCacheItem[] { const relationKeys = new Set(relationInsights.map((item) => insightKey(item))); return [ ...relationInsights, ...legacyInsights.filter((item) => !relationKeys.has(insightKey(item))), ]; } export function buildXiaobaoRisksFromV22Summaries( input: BuildXiaobaoRisksFromV22SummariesInput, ): XiaobaoVersionRisk[] { const now = input.now ?? new Date(); const versionMap = new Map(input.versions.map((version) => [version.id, version])); return input.summaries .map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now)) .sort((a, b) => b.riskScore - a.riskScore); } function mapSummaryToRisk( row: V22XiaobaoWarningSummary, version: VersionWithContext | undefined, snapshots: XiaobaoRiskSnapshot[], now: Date, ): XiaobaoVersionRisk { const payload = getRiskPayload(row.summary); const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention'; const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore); const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence); const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate; const expectedReleaseDate = readString(payload.expectedReleaseDate) ?? version?.expectedReleaseDate ?? version?.releaseDate ?? null; const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate); const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0; const dailyEvidence = readDailyEvidence(payload.dailyEvidence); const silentRisks = readSilentRisks(payload.silentRisks); const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now); const reasons = readReasons(payload.reasons); const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString(); const currentSnapshot = buildCurrentSnapshot({ payload, row, riskLevel, riskScore, confidence, forecastReleaseDate, signals, sourceTime, now, }); const trend = readTrend(payload.trend) ?? summarizeRiskTrendWithCurrent( snapshots.filter((snapshot) => snapshot.versionId === row.versionId), currentSnapshot, ); return { versionId: row.versionId, versionName: readString(payload.versionName) ?? version?.name ?? row.versionId, productId: readString(payload.productId) ?? version?.productId, productName: readString(payload.productName) ?? version?.productName, projectId: readString(payload.projectId) ?? version?.projectId, projectName: readString(payload.projectName) ?? version?.projectName, riskScore, riskLevel, expectedReleaseDate, confidence, confidenceLevel: getConfidenceLevel(confidence), forecastReleaseDate, delayDays, remainingWorkHours, reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore), silentRisks, dailyEvidence, signals, currentSnapshot, trend, }; } function getRiskPayload(summary: unknown): Record { const root = readRecord(summary); const nested = readRecord(root.risk); return Object.keys(nested).length > 0 ? nested : root; } function insightKey(item: XiaobaoRiskInsightCacheItem): string { return `${item.versionId}::${item.riskSignature}`; } function buildCurrentSnapshot(input: { payload: Record; row: V22XiaobaoWarningSummary; riskLevel: XiaobaoRiskLevel; riskScore: number; confidence: number; forecastReleaseDate?: string; signals: XiaobaoRiskSignals; sourceTime: string; now: Date; }): XiaobaoRiskSnapshot { const snapshot = readRecord(input.payload.currentSnapshot); const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString(); return { versionId: input.row.versionId, date: readString(snapshot.date) ?? createdAt.slice(0, 10), riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore), riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel, forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate, openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount, criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount, failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount, blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount, silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount, confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence), createdAt, }; } function readSignals( value: unknown, dailyEvidence: VersionDailyEvidence | undefined, silentRisks: SilentRisk[], expectedReleaseDate: string | null, now: Date, ): XiaobaoRiskSignals { const row = readRecord(value); return { unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0, openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0, criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0, failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0, blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0, silentRiskCount: nonNegativeInteger(readNumber(row.silentRiskCount)) ?? dailyEvidence?.silentRisks?.length ?? silentRisks.length, daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now), }; } function readReasons(value: unknown): RiskReason[] { if (!Array.isArray(value)) return []; return value.map((item) => { const row = readRecord(item); const key = readString(row.key); const title = readString(row.title); const detail = readString(row.detail); if (!key || !title || !detail) return undefined; return { key, title, detail, severity: toReasonSeverity(readString(row.severity)), count: nonNegativeInteger(readNumber(row.count)), }; }).filter(isDefined); } function readSilentRisks(value: unknown): SilentRisk[] { if (!Array.isArray(value)) return []; return value.map((item) => { const row = readRecord(item); const key = readString(row.key); const title = readString(row.title); const detail = readString(row.detail); if (!key || !title || !detail) return undefined; return { key, title, detail, itemId: readString(row.itemId), itemType: toSilentRiskItemType(readString(row.itemType)), }; }).filter(isDefined); } function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined { const row = readRecord(value); if (Object.keys(row).length === 0) return undefined; return { todayDeliveries: readEvidenceItems(row.todayDeliveries), todayProgress: readEvidenceItems(row.todayProgress), todayCreations: readEvidenceItems(row.todayCreations), todayRisks: readEvidenceItems(row.todayRisks), progressNotes: readEvidenceItems(row.progressNotes), needsProgressItems: readEvidenceItems(row.needsProgressItems), recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0, totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0, todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0, lastActivityAt: readString(row.lastActivityAt), silentRisks: readSilentRisks(row.silentRisks), }; } function readEvidenceItems(value: unknown): EvidenceItem[] { if (!Array.isArray(value)) return []; return value.map((item, index) => { const row = readRecord(item); const summary = readString(row.summary); if (!summary) return undefined; return { id: readString(row.id) ?? `v22-evidence-${index}`, title: readString(row.title) ?? summary, summary, occurredAt: readString(row.occurredAt) ?? '', actorId: readString(row.actorId), }; }).filter(isDefined); } function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined { const row = readRecord(value); const direction = readString(row.direction); const summary = readString(row.summary); if (!direction || !summary) return undefined; if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined; const pattern = readString(row.pattern); return { direction, delta: readNumber(row.delta) ?? 0, summary, pattern: pattern === 'continuous_rising' || pattern === 'continuous_falling' || pattern === 'score_delta' || pattern === 'stable' || pattern === 'unknown' ? pattern : undefined, }; } function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] { if (riskLevel === 'on_track') return []; return [{ key: 'precomputed_risk', title: 'Precomputed risk', detail: `V2.2 precomputed summary reports risk score ${riskScore}.`, severity: riskScore >= 75 ? 'danger' : 'warning', }]; } function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined { return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined; } function toReasonSeverity(value: string | undefined): RiskReason['severity'] { return value && REASON_SEVERITIES.has(value as RiskReason['severity']) ? value as RiskReason['severity'] : 'warning'; } function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined { return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable) ? value as NonNullable : undefined; } function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel { if (confidence >= 75) return 'high'; if (confidence >= 50) return 'medium'; return 'low'; } function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number { const expected = parseDate(expectedReleaseDate); const forecast = parseDate(forecastReleaseDate); if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0; return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10; } function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined { const expected = parseDate(expectedReleaseDate); if (!expected) return undefined; return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10; } function parseDate(value: string | null | undefined): Date | undefined { if (!value) return undefined; const date = new Date(value); return Number.isFinite(date.getTime()) ? date : undefined; } function readRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function readString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined; } function readNumber(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim().length > 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function nonNegativeNumber(value: number | undefined): number | undefined { return value !== undefined && value >= 0 ? value : undefined; } function nonNegativeInteger(value: number | undefined): number | undefined { return value !== undefined && value >= 0 ? Math.round(value) : undefined; } function clampScore(value: number): number { return Math.max(0, Math.min(100, Math.round(value))); } function isDefined(value: T | undefined): value is T { return value !== undefined; }