import { buildRiskInsightDisplaySignature, buildRiskInsightSignature, findLatestRiskInsightForVersion, getReusableInsight, } from './xiaobao-risk-ai'; import type { XiaobaoRiskInsight, XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk'; import { sanitizeRiskInsight } from './xiaobao-warning-view'; interface AttachXiaobaoRiskSuggestionInput { insights: XiaobaoRiskInsightCacheItem[]; pendingInsightKeys: string[]; pendingInsightAttempts?: Record; pendingInsightVisibleMs?: number; now?: Date; } const DEFAULT_PENDING_INSIGHT_VISIBLE_MS = 3 * 60 * 1000; const RISK_LEVEL_LABEL: Record = { on_track: '按期', attention: '需关注', at_risk: '有风险', likely_delayed: '大概率延期', blocked: '阻塞', }; export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string { return `${risk.versionId}:${buildRiskInsightDisplaySignature(risk)}`; } export function attachXiaobaoRiskSuggestion( risk: XiaobaoVersionRisk, input: AttachXiaobaoRiskSuggestionInput, ): XiaobaoVersionRisk { const pendingKey = buildXiaobaoRiskInsightPendingKey(risk); const isUpdating = isFreshPendingInsight(pendingKey, input); const reusable = getReusableInsight(input.insights, risk); if (reusable) { return { ...risk, aiInsight: sanitizeRiskInsight(reusable.insight), aiInsightSource: 'current_ai', aiInsightUpdating: false, }; } const latest = findLatestRiskInsightForVersion(input.insights, risk.versionId); if (latest) { return { ...risk, aiInsight: sanitizeRiskInsight(latest.insight), aiInsightSource: 'previous_ai', aiInsightUpdating: isUpdating, }; } return { ...risk, aiInsight: buildRuleBasedRiskInsight(risk, input.now), aiInsightSource: 'rule', aiInsightUpdating: isUpdating, }; } function isFreshPendingInsight(pendingKey: string, input: AttachXiaobaoRiskSuggestionInput): boolean { if (!input.pendingInsightKeys.includes(pendingKey)) return false; const requestedAt = input.pendingInsightAttempts?.[pendingKey]; if (!requestedAt) return true; const requestedTime = new Date(requestedAt).getTime(); const nowTime = (input.now ?? new Date()).getTime(); if (!Number.isFinite(requestedTime) || !Number.isFinite(nowTime)) return false; const visibleMs = input.pendingInsightVisibleMs ?? DEFAULT_PENDING_INSIGHT_VISIBLE_MS; return nowTime - requestedTime < visibleMs; } export function buildRuleBasedRiskInsight(risk: XiaobaoVersionRisk, now = new Date()): XiaobaoRiskInsight { const firstReason = risk.reasons[0]; const why = risk.reasons.length > 0 ? risk.reasons.slice(0, 4).map((reason) => reason.detail || reason.title) : [risk.trend.summary || '当前版本存在规则层识别到的发布风险。']; const forecastDate = formatDate(risk.forecastReleaseDate); const expectedDate = formatDate(risk.expectedReleaseDate); const forecast = risk.delayDays > 0 ? `按当前剩余工作量和风险信号,预计较期望发版日期${expectedDate ? `(${expectedDate})` : ''}延期约 ${risk.delayDays} 天,可发窗口约为 ${forecastDate || '重新评估后确认'}。` : `按当前规则预测,版本仍有风险需要收敛;预计可发窗口为 ${forecastDate || expectedDate || '待补齐计划后确认'}。`; return sanitizeRiskInsight({ summary: `${risk.versionName} 当前风险分 ${risk.riskScore},状态为${RISK_LEVEL_LABEL[risk.riskLevel]}。${firstReason?.title ? `主要关注:${firstReason.title}。` : ''}`, why, forecast, recommendedReleaseWindow: buildReleaseWindow(risk), suggestedActions: buildRuleActions(risk), ownerHints: buildOwnerHints(risk), generatedAt: now.toISOString(), }); } function buildReleaseWindow(risk: XiaobaoVersionRisk): string | undefined { const forecastDate = formatDate(risk.forecastReleaseDate); if (!forecastDate) return undefined; if (risk.delayDays > 0) return `建议优先以 ${forecastDate} 作为新的候选发版窗口,并在关键风险关闭后再确认。`; return `建议继续以 ${forecastDate} 附近作为候选发版窗口,但需要先完成当前风险项收敛。`; } function buildRuleActions(risk: XiaobaoVersionRisk): string[] { const actions = risk.reasons.flatMap(reasonToActions); const unique = Array.from(new Set(actions)); if (unique.length > 0) return unique.slice(0, 4); return ['确认剩余任务、测试失败、Bug 和阻塞项的负责人及预计完成时间。']; } function reasonToActions(reason: RiskReason): string[] { switch (reason.key) { case 'critical_bug': return ['优先收敛 P1 或致命 Bug,明确修复人、复测人和关闭时间。']; case 'failed_test': return ['安排测试负责人复测失败用例,并同步失败原因和修复进展。']; case 'blocked_work': return ['先解除阻塞项,无法当天解除的需要明确替代方案或延期影响。']; case 'forecast_delay': case 'remaining_work': return ['按剩余工作量重排今天到发版日前的完成顺序,先处理影响发版的主链路事项。']; case 'silent_risk': return ['要求未更新的负责人补充今日日报或活动证据,避免静默风险继续扩大。']; default: return reason.detail ? [`跟进:${reason.detail}`] : []; } } function buildOwnerHints(risk: XiaobaoVersionRisk): string[] { const hints: string[] = []; if (risk.signals.unfinishedCount > 0) hints.push(`仍有 ${risk.signals.unfinishedCount} 个未完成事项,需要逐项确认负责人。`); if (risk.signals.openBugCount > 0) hints.push(`仍有 ${risk.signals.openBugCount} 个未关闭 Bug,需要确认修复和复测节奏。`); if (risk.signals.silentRiskCount > 0) hints.push(`存在 ${risk.signals.silentRiskCount} 个静默风险,需要负责人补充进展。`); return hints; } function formatDate(value: string | null | undefined): string | undefined { if (!value) return undefined; const raw = value.trim(); if (!raw) return undefined; 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 undefined; return new Date(time).toISOString().slice(0, 10); }