Files
ftb-project-management/apps/web/lib/xiaobao-risk-suggestion.ts
Script Generator e5ef9ee28b fix(小宝预警): 稳定风险证据与建议更新
关键改动:

- 优化风险证据的日报工时与静默风险计算

- 稳定 AI 触发签名,避免证据细节变化造成重复请求

- 调整预警建议更新状态与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:26:16 +08:00

151 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>;
pendingInsightVisibleMs?: number;
now?: Date;
}
const DEFAULT_PENDING_INSIGHT_VISIBLE_MS = 3 * 60 * 1000;
const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
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);
}