feat(小宝预警): 增加 AI 解读触发策略

This commit is contained in:
Script Generator
2026-06-29 19:32:26 +08:00
parent 064617aba6
commit 94a038494a
6 changed files with 487 additions and 0 deletions

View 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)));
}