feat(v2.2): 完成高频读取热路径

This commit is contained in:
Script Generator
2026-07-03 11:53:25 +08:00
parent 5a90923031
commit 70460c3273
28 changed files with 3042 additions and 121 deletions

View File

@@ -0,0 +1,345 @@
import type { VersionWithContext } from './derive';
import type { V22XiaobaoWarningSummary } from './v22-api';
import type {
RiskReason,
SilentRisk,
XiaobaoConfidenceLevel,
XiaobaoRiskLevel,
XiaobaoRiskSignals,
XiaobaoVersionRisk,
} from './xiaobao-risk';
import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence';
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
interface BuildXiaobaoRisksFromV22SummariesInput {
summaries: V22XiaobaoWarningSummary[];
versions: VersionWithContext[];
snapshots?: XiaobaoRiskSnapshot[];
now?: Date;
}
export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed';
const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']);
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean {
return state === 'empty' || state === 'failed';
}
export function filterV22XiaobaoSummariesForVisibleVersions(
summaries: V22XiaobaoWarningSummary[],
versions: VersionWithContext[],
): V22XiaobaoWarningSummary[] {
const visibleVersionIds = new Set(versions.map((version) => version.id));
if (visibleVersionIds.size === 0) return [];
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
}
export function buildXiaobaoRisksFromV22Summaries(
input: BuildXiaobaoRisksFromV22SummariesInput,
): XiaobaoVersionRisk[] {
const now = input.now ?? new Date();
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
return input.summaries
.map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now))
.sort((a, b) => b.riskScore - a.riskScore);
}
function mapSummaryToRisk(
row: V22XiaobaoWarningSummary,
version: VersionWithContext | undefined,
snapshots: XiaobaoRiskSnapshot[],
now: Date,
): XiaobaoVersionRisk {
const payload = getRiskPayload(row.summary);
const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention';
const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore);
const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence);
const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate;
const expectedReleaseDate =
readString(payload.expectedReleaseDate)
?? version?.expectedReleaseDate
?? version?.releaseDate
?? null;
const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate);
const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0;
const dailyEvidence = readDailyEvidence(payload.dailyEvidence);
const silentRisks = readSilentRisks(payload.silentRisks);
const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now);
const reasons = readReasons(payload.reasons);
const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString();
const currentSnapshot = buildCurrentSnapshot({
payload,
row,
riskLevel,
riskScore,
confidence,
forecastReleaseDate,
signals,
sourceTime,
now,
});
const trend = readTrend(payload.trend)
?? summarizeRiskTrendWithCurrent(
snapshots.filter((snapshot) => snapshot.versionId === row.versionId),
currentSnapshot,
);
return {
versionId: row.versionId,
versionName: readString(payload.versionName) ?? version?.name ?? row.versionId,
productId: readString(payload.productId) ?? version?.productId,
productName: readString(payload.productName) ?? version?.productName,
projectId: readString(payload.projectId) ?? version?.projectId,
projectName: readString(payload.projectName) ?? version?.projectName,
riskScore,
riskLevel,
expectedReleaseDate,
confidence,
confidenceLevel: getConfidenceLevel(confidence),
forecastReleaseDate,
delayDays,
remainingWorkHours,
reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore),
silentRisks,
dailyEvidence,
signals,
currentSnapshot,
trend,
};
}
function getRiskPayload(summary: unknown): Record<string, unknown> {
const root = readRecord(summary);
const nested = readRecord(root.risk);
return Object.keys(nested).length > 0 ? nested : root;
}
function buildCurrentSnapshot(input: {
payload: Record<string, unknown>;
row: V22XiaobaoWarningSummary;
riskLevel: XiaobaoRiskLevel;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
signals: XiaobaoRiskSignals;
sourceTime: string;
now: Date;
}): XiaobaoRiskSnapshot {
const snapshot = readRecord(input.payload.currentSnapshot);
const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString();
return {
versionId: input.row.versionId,
date: readString(snapshot.date) ?? createdAt.slice(0, 10),
riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore),
riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel,
forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate,
openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount,
criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount,
failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount,
blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount,
silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount,
confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence),
createdAt,
};
}
function readSignals(
value: unknown,
dailyEvidence: VersionDailyEvidence | undefined,
silentRisks: SilentRisk[],
expectedReleaseDate: string | null,
now: Date,
): XiaobaoRiskSignals {
const row = readRecord(value);
return {
unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0,
openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0,
criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0,
failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0,
blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0,
silentRiskCount:
nonNegativeInteger(readNumber(row.silentRiskCount))
?? dailyEvidence?.silentRisks?.length
?? silentRisks.length,
daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now),
};
}
function readReasons(value: unknown): RiskReason[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
severity: toReasonSeverity(readString(row.severity)),
count: nonNegativeInteger(readNumber(row.count)),
};
}).filter(isDefined);
}
function readSilentRisks(value: unknown): SilentRisk[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
itemId: readString(row.itemId),
itemType: toSilentRiskItemType(readString(row.itemType)),
};
}).filter(isDefined);
}
function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined {
const row = readRecord(value);
if (Object.keys(row).length === 0) return undefined;
return {
todayDeliveries: readEvidenceItems(row.todayDeliveries),
todayProgress: readEvidenceItems(row.todayProgress),
todayCreations: readEvidenceItems(row.todayCreations),
todayRisks: readEvidenceItems(row.todayRisks),
progressNotes: readEvidenceItems(row.progressNotes),
needsProgressItems: readEvidenceItems(row.needsProgressItems),
recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0,
totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0,
todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0,
lastActivityAt: readString(row.lastActivityAt),
silentRisks: readSilentRisks(row.silentRisks),
};
}
function readEvidenceItems(value: unknown): EvidenceItem[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => {
const row = readRecord(item);
const summary = readString(row.summary);
if (!summary) return undefined;
return {
id: readString(row.id) ?? `v22-evidence-${index}`,
title: readString(row.title) ?? summary,
summary,
occurredAt: readString(row.occurredAt) ?? '',
actorId: readString(row.actorId),
};
}).filter(isDefined);
}
function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined {
const row = readRecord(value);
const direction = readString(row.direction);
const summary = readString(row.summary);
if (!direction || !summary) return undefined;
if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined;
const pattern = readString(row.pattern);
return {
direction,
delta: readNumber(row.delta) ?? 0,
summary,
pattern: pattern === 'continuous_rising'
|| pattern === 'continuous_falling'
|| pattern === 'score_delta'
|| pattern === 'stable'
|| pattern === 'unknown'
? pattern
: undefined,
};
}
function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] {
if (riskLevel === 'on_track') return [];
return [{
key: 'precomputed_risk',
title: 'Precomputed risk',
detail: `V2.2 precomputed summary reports risk score ${riskScore}.`,
severity: riskScore >= 75 ? 'danger' : 'warning',
}];
}
function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined {
return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined;
}
function toReasonSeverity(value: string | undefined): RiskReason['severity'] {
return value && REASON_SEVERITIES.has(value as RiskReason['severity'])
? value as RiskReason['severity']
: 'warning';
}
function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined {
return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable<SilentRisk['itemType']>)
? value as NonNullable<SilentRisk['itemType']>
: undefined;
}
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
if (confidence >= 75) return 'high';
if (confidence >= 50) return 'medium';
return 'low';
}
function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number {
const expected = parseDate(expectedReleaseDate);
const forecast = parseDate(forecastReleaseDate);
if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0;
return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10;
}
function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined {
const expected = parseDate(expectedReleaseDate);
if (!expected) return undefined;
return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10;
}
function parseDate(value: string | null | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
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 | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function nonNegativeNumber(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? value : undefined;
}
function nonNegativeInteger(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? Math.round(value) : undefined;
}
function clampScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}