feat(v2.2): 完成高频读取热路径
This commit is contained in:
345
apps/web/lib/xiaobao-v22-summary.ts
Normal file
345
apps/web/lib/xiaobao-v22-summary.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user