fix(小宝预警): 保证建议不为空并显示更新中

This commit is contained in:
Script Generator
2026-06-30 14:55:13 +08:00
parent 1daa2f7ec4
commit b365ce65a3
7 changed files with 356 additions and 17 deletions

View File

@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRiskInsightSignature } from './xiaobao-risk-ai';
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
import type { XiaobaoVersionRisk } from './xiaobao-risk';
import { attachXiaobaoRiskSuggestion } from './xiaobao-risk-suggestion';
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
return {
versionId: 'ver-1',
versionName: 'V1.0',
productId: 'prod-1',
productName: 'Product A',
projectId: 'proj-1',
projectName: 'Project A',
riskScore: 68,
riskLevel: 'attention',
expectedReleaseDate: '2026-07-05',
confidence: 80,
confidenceLevel: 'high',
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
delayDays: 0,
remainingWorkHours: 8,
reasons: [
{
key: 'remaining_work',
title: '剩余工作量',
detail: '预计还剩 8h 工作量。',
severity: 'warning',
},
],
silentRisks: [],
signals: {
unfinishedCount: 1,
openBugCount: 0,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
daysToExpectedRelease: 2,
},
currentSnapshot: {
versionId: 'ver-1',
date: '2026-06-30',
riskScore: 68,
riskLevel: 'attention',
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
openBugCount: 0,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-06-30T10:00:00.000Z',
},
trend: { direction: 'flat', delta: 0, summary: '风险保持稳定。', pattern: 'stable' },
...patch,
};
}
function insight(itemRisk: XiaobaoVersionRisk, patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
return {
versionId: itemRisk.versionId,
riskSignature: buildRiskInsightSignature(itemRisk),
insight: {
summary: '旧的小宝建议',
why: ['仍有工作未完成'],
forecast: '当前建议继续观察。',
suggestedActions: ['确认剩余任务负责人和完成时间。'],
ownerHints: [],
generatedAt: '2026-06-30T09:00:00.000Z',
},
generatedAt: '2026-06-30T09:00:00.000Z',
...patch,
};
}
test('attachXiaobaoRiskSuggestion provides a rule suggestion when AI cache is empty', () => {
const result = attachXiaobaoRiskSuggestion(risk(), {
insights: [],
pendingInsightKeys: [],
});
assert.equal(result.aiInsightSource, 'rule');
assert.equal(result.aiInsightUpdating, false);
assert.ok(result.aiInsight?.summary);
assert.ok(result.aiInsight?.forecast);
assert.ok((result.aiInsight?.suggestedActions.length ?? 0) > 0);
});
test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one is updating', () => {
const previous = risk({ riskScore: 52, riskLevel: 'attention' });
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
const pendingKey = `${current.versionId}:${buildRiskInsightSignature(current)}`;
const result = attachXiaobaoRiskSuggestion(current, {
insights: [insight(previous)],
pendingInsightKeys: [pendingKey],
});
assert.equal(result.aiInsightSource, 'previous_ai');
assert.equal(result.aiInsightUpdating, true);
assert.equal(result.aiInsight?.summary, '旧的小宝建议');
});
test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signature matches', () => {
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
const result = attachXiaobaoRiskSuggestion(current, {
insights: [insight(current, {
insight: {
summary: '新的小宝建议',
why: ['风险已升高'],
forecast: '预计延期 1 天。',
suggestedActions: ['先处理阻塞和失败用例。'],
ownerHints: [],
generatedAt: '2026-06-30T10:00:00.000Z',
},
generatedAt: '2026-06-30T10:00:00.000Z',
})],
pendingInsightKeys: [],
});
assert.equal(result.aiInsightSource, 'current_ai');
assert.equal(result.aiInsightUpdating, false);
assert.equal(result.aiInsight?.summary, '新的小宝建议');
});

View File

@@ -0,0 +1,128 @@
import { 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[];
now?: Date;
}
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}:${buildRiskInsightSignature(risk)}`;
}
export function attachXiaobaoRiskSuggestion(
risk: XiaobaoVersionRisk,
input: AttachXiaobaoRiskSuggestionInput,
): XiaobaoVersionRisk {
const pendingKey = buildXiaobaoRiskInsightPendingKey(risk);
const isUpdating = input.pendingInsightKeys.includes(pendingKey);
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,
};
}
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);
}

View File

@@ -70,6 +70,8 @@ export interface XiaobaoVersionRisk {
signals: XiaobaoRiskSignals;
currentSnapshot: XiaobaoRiskSnapshot;
aiInsight?: XiaobaoRiskInsight;
aiInsightUpdating?: boolean;
aiInsightSource?: 'current_ai' | 'previous_ai' | 'rule';
trend: {
direction: 'up' | 'down' | 'flat' | 'unknown';
delta: number;