关键改动: - 优化风险证据的日报工时与静默风险计算 - 稳定 AI 触发签名,避免证据细节变化造成重复请求 - 调整预警建议更新状态与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
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'
|
|
>;
|
|
|
|
export 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;
|
|
const RISK_INSIGHT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
const RISK_LEVEL_RANK: Record<XiaobaoVersionRisk['riskLevel'], number> = {
|
|
on_track: 0,
|
|
attention: 1,
|
|
at_risk: 2,
|
|
likely_delayed: 3,
|
|
blocked: 4,
|
|
};
|
|
|
|
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 findLatestRiskInsightForVersion(
|
|
cache: XiaobaoRiskInsightCacheItem[],
|
|
versionId: string,
|
|
): XiaobaoRiskInsightCacheItem | undefined {
|
|
return cache
|
|
.filter((item) => item.versionId === versionId)
|
|
.sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0];
|
|
}
|
|
|
|
export function shouldRequestRiskInsightWithCooldown(
|
|
current: RiskInsightCurrent,
|
|
previous?: RiskInsightPrevious,
|
|
latestInsight?: XiaobaoRiskInsightCacheItem,
|
|
now: Date = new Date(),
|
|
): boolean {
|
|
if (!shouldRequestRiskInsight(current, previous)) return false;
|
|
if (!latestInsight) return true;
|
|
if (isRiskLevelEscalation(current.riskLevel, latestInsight)) return true;
|
|
|
|
const generatedAt = getTime(latestInsight.generatedAt);
|
|
const nowTime = now.getTime();
|
|
if (!Number.isFinite(generatedAt) || !Number.isFinite(nowTime)) return true;
|
|
return nowTime - generatedAt >= RISK_INSIGHT_COOLDOWN_MS;
|
|
}
|
|
|
|
export function shouldRequestRiskInsightWithCacheGate(
|
|
riskCacheLoaded: boolean,
|
|
cache: XiaobaoRiskInsightCacheItem[],
|
|
current: XiaobaoVersionRisk,
|
|
previous?: RiskInsightPrevious,
|
|
now: Date = new Date(),
|
|
): boolean {
|
|
if (!riskCacheLoaded) return false;
|
|
if (getReusableInsight(cache, current)) return false;
|
|
const latestInsight = findLatestRiskInsightForVersion(cache, current.versionId);
|
|
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
|
}
|
|
|
|
export interface RiskInsightRequestGateInput {
|
|
riskCacheLoaded: boolean;
|
|
cache: XiaobaoRiskInsightCacheItem[];
|
|
current: XiaobaoVersionRisk;
|
|
previous?: RiskInsightPrevious;
|
|
lastRequestedAt?: string;
|
|
now?: Date;
|
|
}
|
|
|
|
export function shouldRequestRiskInsightWithRequestGate(input: RiskInsightRequestGateInput): boolean {
|
|
const now = input.now ?? new Date();
|
|
if (isRiskInsightRequestCoolingDown(input.lastRequestedAt, now)) return false;
|
|
return shouldRequestRiskInsightWithCacheGate(
|
|
input.riskCacheLoaded,
|
|
input.cache,
|
|
input.current,
|
|
input.previous,
|
|
now,
|
|
);
|
|
}
|
|
|
|
export function isRiskInsightRequestCoolingDown(lastRequestedAt: string | undefined, now: Date = new Date()): boolean {
|
|
if (!lastRequestedAt) return false;
|
|
const requestedAt = getTime(lastRequestedAt);
|
|
const nowTime = now.getTime();
|
|
if (!Number.isFinite(requestedAt) || !Number.isFinite(nowTime)) return false;
|
|
return nowTime - requestedAt < RISK_INSIGHT_COOLDOWN_MS;
|
|
}
|
|
|
|
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
|
return JSON.stringify({
|
|
versionId: risk.versionId,
|
|
riskScore: clampScore(risk.riskScore),
|
|
riskLevel: risk.riskLevel,
|
|
expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate),
|
|
forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate),
|
|
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: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease),
|
|
},
|
|
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),
|
|
todayCreations: normalizeEvidence(risk.dailyEvidence?.todayCreations),
|
|
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
|
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
|
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
|
totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0,
|
|
todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0,
|
|
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function buildRiskInsightDisplaySignature(risk: XiaobaoVersionRisk): string {
|
|
return normalizeRiskInsightDisplaySignature(buildRiskInsightSignature(risk)) ?? buildRiskInsightSignature(risk);
|
|
}
|
|
|
|
export function normalizeRiskInsightDisplaySignature(signature: string): string | undefined {
|
|
return buildDisplayCompatibilityKey(signature);
|
|
}
|
|
|
|
export function getReusableInsight(
|
|
cache: XiaobaoRiskInsightCacheItem[],
|
|
risk: XiaobaoVersionRisk,
|
|
): XiaobaoRiskInsightCacheItem | undefined {
|
|
const currentSignature = buildRiskInsightSignature(risk);
|
|
return (
|
|
findCachedInsight(cache, risk.versionId, currentSignature) ??
|
|
cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature) ??
|
|
findLatestDisplayCompatibleInsight(cache, risk.versionId, currentSignature)
|
|
);
|
|
}
|
|
|
|
export function findPreviousRiskSnapshot(
|
|
snapshots: XiaobaoRiskSnapshot[],
|
|
versionId: string,
|
|
today: string,
|
|
): XiaobaoRiskSnapshot | undefined {
|
|
return snapshots
|
|
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date < today)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
|
}
|
|
|
|
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),
|
|
todayCreations: summarizeEvidence(risk.dailyEvidence?.todayCreations),
|
|
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
|
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
|
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
|
totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0,
|
|
todayActualHours: risk.dailyEvidence?.todayActualHours ?? 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)));
|
|
}
|
|
|
|
function isRiskLevelEscalation(
|
|
currentLevel: XiaobaoVersionRisk['riskLevel'],
|
|
latestInsight: XiaobaoRiskInsightCacheItem,
|
|
): boolean {
|
|
const previousLevel = parseRiskLevel(latestInsight.riskSignature);
|
|
if (!previousLevel) return false;
|
|
return RISK_LEVEL_RANK[currentLevel] > RISK_LEVEL_RANK[previousLevel];
|
|
}
|
|
|
|
function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | undefined {
|
|
try {
|
|
const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoVersionRisk['riskLevel'] };
|
|
return parsed.riskLevel && parsed.riskLevel in RISK_LEVEL_RANK ? parsed.riskLevel : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function normalizeCachedRiskSignature(signature: string): string | undefined {
|
|
try {
|
|
const parsed = JSON.parse(signature) as {
|
|
expectedReleaseDate?: string | null;
|
|
forecastReleaseDate?: string | null;
|
|
signals?: { daysToExpectedRelease?: number | null };
|
|
};
|
|
return JSON.stringify({
|
|
...parsed,
|
|
expectedReleaseDate: normalizeDateKey(parsed.expectedReleaseDate),
|
|
forecastReleaseDate: normalizeDateKey(parsed.forecastReleaseDate),
|
|
signals: {
|
|
...parsed.signals,
|
|
daysToExpectedRelease: normalizeDaysToExpectedRelease(parsed.signals?.daysToExpectedRelease),
|
|
},
|
|
});
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function findLatestDisplayCompatibleInsight(
|
|
cache: XiaobaoRiskInsightCacheItem[],
|
|
versionId: string,
|
|
currentSignature: string,
|
|
): XiaobaoRiskInsightCacheItem | undefined {
|
|
const currentKey = buildDisplayCompatibilityKey(currentSignature);
|
|
if (!currentKey) return undefined;
|
|
return cache
|
|
.filter((item) => item.versionId === versionId && buildDisplayCompatibilityKey(item.riskSignature) === currentKey)
|
|
.sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0];
|
|
}
|
|
|
|
function buildDisplayCompatibilityKey(signature: string): string | undefined {
|
|
try {
|
|
const parsed = JSON.parse(signature) as Record<string, unknown>;
|
|
const signals = readRecord(parsed.signals);
|
|
const trend = readRecord(parsed.trend);
|
|
return JSON.stringify({
|
|
versionId: readString(parsed.versionId),
|
|
riskScore: normalizeInteger(parsed.riskScore),
|
|
riskLevel: readString(parsed.riskLevel),
|
|
expectedReleaseDate: normalizeDateKey(readString(parsed.expectedReleaseDate)),
|
|
delayDays: normalizeInteger(parsed.delayDays),
|
|
confidence: normalizeInteger(parsed.confidence),
|
|
signals: {
|
|
unfinishedCount: normalizeInteger(signals.unfinishedCount),
|
|
openBugCount: normalizeInteger(signals.openBugCount),
|
|
criticalBugCount: normalizeInteger(signals.criticalBugCount),
|
|
failedTestCount: normalizeInteger(signals.failedTestCount),
|
|
blockedCount: normalizeInteger(signals.blockedCount),
|
|
silentRiskCount: normalizeInteger(signals.silentRiskCount),
|
|
},
|
|
trend: {
|
|
direction: readString(trend.direction),
|
|
delta: normalizeInteger(trend.delta),
|
|
pattern: readString(trend.pattern),
|
|
},
|
|
reasons: normalizeCompatibilityReasons(parsed.reasons),
|
|
silentRisks: normalizeCompatibilitySilentRisks(parsed.silentRisks),
|
|
});
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function normalizeCompatibilityReasons(rows: unknown) {
|
|
return asRecordArray(rows)
|
|
.map((row) => ({
|
|
key: readString(row.key),
|
|
severity: readString(row.severity),
|
|
detail: readString(row.detail),
|
|
count: normalizeInteger(row.count),
|
|
}))
|
|
.sort(compareSignatureRows);
|
|
}
|
|
|
|
function normalizeCompatibilitySilentRisks(rows: unknown) {
|
|
return asRecordArray(rows)
|
|
.map((row) => ({
|
|
key: readString(row.key),
|
|
itemId: readString(row.itemId),
|
|
itemType: readString(row.itemType),
|
|
}))
|
|
.sort(compareSignatureRows);
|
|
}
|
|
|
|
function asRecordArray(rows: unknown): Array<Record<string, unknown>> {
|
|
return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : [];
|
|
}
|
|
|
|
function readRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === 'string' ? value : null;
|
|
}
|
|
|
|
function normalizeDateKey(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const raw = value.trim();
|
|
if (raw.length === 0) return null;
|
|
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 raw;
|
|
return new Date(time).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function normalizeDaysToExpectedRelease(value: number | null | undefined): number | null {
|
|
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
|
return value >= 0 ? Math.ceil(value) : Math.floor(value);
|
|
}
|
|
|
|
function normalizeInteger(value: unknown): number | null {
|
|
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
|
}
|
|
|
|
function getTime(value: string): number {
|
|
const time = new Date(value).getTime();
|
|
return Number.isFinite(time) ? time : Number.NaN;
|
|
}
|