feat(小宝预警): 增加 AI 解读触发策略
This commit is contained in:
211
apps/web/lib/xiaobao-risk-ai.ts
Normal file
211
apps/web/lib/xiaobao-risk-ai.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
|
||||
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 buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||
return JSON.stringify({
|
||||
versionId: risk.versionId,
|
||||
riskScore: clampScore(risk.riskScore),
|
||||
riskLevel: risk.riskLevel,
|
||||
expectedReleaseDate: risk.expectedReleaseDate ?? null,
|
||||
forecastReleaseDate: risk.forecastReleaseDate ?? null,
|
||||
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: risk.signals.daysToExpectedRelease ?? null,
|
||||
},
|
||||
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),
|
||||
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getReusableInsight(
|
||||
cache: XiaobaoRiskInsightCacheItem[],
|
||||
risk: XiaobaoVersionRisk,
|
||||
): XiaobaoRiskInsightCacheItem | undefined {
|
||||
return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
|
||||
}
|
||||
|
||||
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),
|
||||
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestRiskInsight(
|
||||
risk: XiaobaoVersionRisk,
|
||||
): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||
return api.postRaw<AgentRiskInterpretResponse | AgentRiskInterpretError>(
|
||||
'/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<T>(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)));
|
||||
}
|
||||
Reference in New Issue
Block a user