feat(小宝预警): 增加日报证据与风险趋势

This commit is contained in:
Script Generator
2026-06-29 18:05:58 +08:00
parent 585316d1cf
commit b48a7e2049
5 changed files with 636 additions and 23 deletions

View File

@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { TaskWorklog } from './task-worklog';
import type { VersionPlan } from './version-plan';
import type { WorkActivity } from './work-activity';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence';
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
return {
id: 'plan-1',
versionId: 'ver-1',
type: 'product',
title: 'Product plan',
owner: 'pm-1',
startTime: '2026-06-20T01:00:00.000Z',
endTime: '2026-06-25T10:00:00.000Z',
status: 'in_progress',
actualStartAt: '2026-06-20T01:00:00.000Z',
createdAt: '2026-06-20T01:00:00.000Z',
addedBy: 'pm-1',
...patch,
};
}
function devTask(patch: Partial<DevTask> = {}): DevTask {
return {
id: 'dev-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: 'Build warning panel',
categoryId: 'frontend',
assigneeId: 'dev-other',
priority: 'P1',
expectedStartAt: '2026-06-20T01:00:00.000Z',
expectedEndAt: '2026-06-26T10:00:00.000Z',
status: 'in_progress',
isBlocked: false,
actualStartAt: '2026-06-20T01:00:00.000Z',
createdBy: 'pm-1',
createdAt: '2026-06-20T01:00:00.000Z',
updatedAt: '2026-06-24T02:00:00.000Z',
...patch,
};
}
function testCase(patch: Partial<TestCase> = {}): TestCase {
return {
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'ver-1',
title: 'Verify warning panel',
categoryId: 'ui-interaction',
priority: 'P2',
assigneeId: 'qa-other',
status: 'running',
startedAt: '2026-06-20T03:00:00.000Z',
createdBy: 'qa-other',
createdAt: '2026-06-20T01:00:00.000Z',
updatedAt: '2026-06-24T02:00:00.000Z',
...patch,
};
}
function bug(patch: Partial<Bug> = {}): Bug {
return {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'ver-1',
testCaseId: 'tc-1',
title: 'Warning count mismatch',
description: 'The count is wrong.',
severity: 'major',
priority: 'P1',
reportedBy: 'qa-other',
assigneeId: 'dev-other',
status: 'open',
createdAt: '2026-06-20T01:00:00.000Z',
updatedAt: '2026-06-24T02:00:00.000Z',
...patch,
};
}
test('buildVersionDailyEvidence groups version activity and detects no activity/no update', () => {
const workItems = buildXiaobaoWorkItems({
plans: [plan()],
devTasks: [devTask()],
testCases: [testCase()],
bugs: [bug()],
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
requirementVersionMap: new Map([['req-1', 'ver-1']]),
});
const activities: WorkActivity[] = [
{
id: 'act-delivery',
actorId: 'dev-other',
date: '2026-06-29',
occurredAt: '2026-06-29T02:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'dev-1',
action: 'dev_task_submitted',
category: 'delivery',
title: 'Build warning panel',
summary: 'Submitted development task',
},
{
id: 'act-risk',
actorId: 'qa-other',
date: '2026-06-29',
occurredAt: '2026-06-29T03:00:00.000Z',
sourceType: 'bug',
sourceId: 'bug-1',
action: 'bug_blocked',
category: 'risk',
title: 'Warning count mismatch',
summary: 'Blocked by missing data',
},
];
const worklogs: TaskWorklog[] = [
{
id: 'wl-note',
taskId: 'tc-1',
userId: 'qa-other',
date: '2026-06-29',
hours: 1,
workContent: 'Continued regression testing.',
createdAt: '2026-06-29T04:00:00.000Z',
},
];
const evidence = buildVersionDailyEvidence({
versionId: 'ver-1',
workItems,
activities,
worklogs,
now: new Date('2026-06-29T08:00:00.000Z'),
});
assert.deepEqual(evidence.todayDeliveries.map((item) => item.id), ['act-delivery']);
assert.deepEqual(evidence.todayRisks.map((item) => item.id), ['act-risk']);
assert.deepEqual(evidence.progressNotes.map((item) => item.id), ['wl-note']);
assert.equal(evidence.recentActivityCount, 3);
assert.equal(evidence.lastActivityAt, '2026-06-29T04:00:00.000Z');
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_update' && risk.itemId === 'plan-1'));
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
});
test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => {
const items = buildXiaobaoWorkItems({
plans: [plan({ owner: 'pm-other' })],
devTasks: [devTask({ assigneeId: 'dev-other' })],
testCases: [testCase({ assigneeId: 'qa-other' })],
bugs: [bug({ assigneeId: 'dev-other' })],
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
requirementVersionMap: new Map([['req-1', 'ver-1']]),
});
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
});

View File

@@ -1,4 +1,11 @@
import type { SilentRisk } from './xiaobao-risk'; import type { SilentRisk } from './xiaobao-risk';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TaskWorklog } from './task-worklog';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
import type { WorkActivity } from './work-activity';
import type { WorkItem } from './workspace-engine';
export interface EvidenceItem { export interface EvidenceItem {
id: string; id: string;
@@ -18,3 +25,336 @@ export interface VersionDailyEvidence {
lastActivityAt?: string; lastActivityAt?: string;
silentRisks?: SilentRisk[]; silentRisks?: SilentRisk[];
} }
export interface BuildVersionDailyEvidenceInput {
versionId: string;
workItems: WorkItem[];
activities?: WorkActivity[];
worklogs?: TaskWorklog[];
now?: Date;
}
export interface XiaobaoVersionContext {
id: string;
name: string;
productName?: string;
projectName?: string;
}
export interface BuildXiaobaoWorkItemsInput {
plans?: VersionPlan[];
devTasks?: DevTask[];
testCases?: TestCase[];
bugs?: Bug[];
versions: XiaobaoVersionContext[];
requirementVersionMap: Map<string, string> | Record<string, string>;
}
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
const items: WorkItem[] = [];
for (const plan of input.plans ?? []) {
const version = versionMap.get(plan.versionId);
items.push({
id: plan.id,
type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
title: plan.title,
status: plan.status,
completed: plan.status === 'completed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: plan.versionId,
extra: {
startTime: plan.startTime,
endTime: plan.endTime,
actualStartAt: plan.actualStartAt,
completedAt: plan.completedAt,
},
raw: plan,
});
}
for (const task of input.devTasks ?? []) {
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
const version = versionMap.get(versionId);
items.push({
id: task.id,
type: 'devTask',
title: task.title,
status: task.status,
completed: task.status === 'submitted',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId,
priority: task.priority,
extra: {
taskNo: task.taskNo,
expectedStartAt: task.expectedStartAt,
expectedEndAt: task.expectedEndAt,
actualStartAt: task.actualStartAt,
actualEndAt: task.actualEndAt,
isBlocked: task.isBlocked,
blockReason: task.blockReason,
},
raw: task,
});
}
for (const testCase of input.testCases ?? []) {
const version = versionMap.get(testCase.versionId);
items.push({
id: testCase.id,
type: 'testCase',
title: testCase.title,
status: testCase.status,
completed: testCase.status === 'passed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: testCase.versionId,
priority: testCase.priority,
extra: {
caseNo: testCase.caseNo,
requirementId: testCase.requirementId,
plannedTestAt: testCase.plannedTestAt,
plannedEndAt: testCase.plannedEndAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
},
raw: testCase,
});
}
for (const bug of input.bugs ?? []) {
const version = versionMap.get(bug.versionId);
items.push({
id: bug.id,
type: 'bug',
title: bug.title,
status: bug.status,
completed: bug.status === 'closed' || bug.status === 'rejected',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: bug.versionId,
priority: bug.priority,
extra: {
bugNo: bug.bugNo,
severity: bug.severity,
testCaseId: bug.testCaseId,
plannedFixAt: bug.plannedFixAt,
},
raw: bug,
});
}
return items;
}
export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
const now = input.now ?? new Date();
const today = now.toISOString().slice(0, 10);
const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
const versionItemIds = new Set(versionItems.map((item) => item.id));
const itemMap = new Map(versionItems.map((item) => [item.id, item]));
const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId));
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
const todayActivities = versionActivities.filter((activity) => activity.date === today);
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
const recentSince = now.getTime() - 3 * 86_400_000;
const recentActivityCount = [
...versionActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
].filter((value) => {
const date = parseDate(value);
return Boolean(date && date.getTime() >= recentSince);
}).length;
const lastActivityAt = latestIso([
...versionActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
]);
const touchedTodayIds = new Set([
...todayActivities.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
return {
todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem),
todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem),
todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem),
progressNotes: [
...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem),
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
needsProgressItems: versionItems
.filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now))
.map(itemToEvidenceItem),
recentActivityCount,
lastActivityAt,
silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now),
};
}
function normalizeRequirementVersionMap(input: Map<string, string> | Record<string, string>): Map<string, string> {
return input instanceof Map ? input : new Map(Object.entries(input));
}
function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
return {
id: activity.id,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
actorId: activity.actorId,
};
}
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
return {
id: worklog.id,
title: item?.title ?? worklog.taskId,
summary: worklog.workContent,
occurredAt: worklog.createdAt,
actorId: worklog.userId,
};
}
function itemToEvidenceItem(item: WorkItem): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: `${item.status}`,
occurredAt: getLatestItemTouchAt(item) ?? '',
};
}
function buildSilentRisks(
items: WorkItem[],
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): SilentRisk[] {
const risks: SilentRisk[] = [];
for (const item of items) {
if (item.completed) continue;
const latestUpdateAt = getLatestItemTouchAt(item);
const latestEvidenceAt = latestIso([
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
const evidenceDays = daysSince(latestEvidenceAt, now);
const updateDays = daysSince(latestUpdateAt, now);
const touchDays = daysSince(latestTouchAt, now);
if (updateDays >= 5) {
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
}
if (evidenceDays >= 8) {
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
}
if (evidenceDays >= 4) {
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
}
if (isActiveUnfinished(item) && touchDays >= 3) {
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
}
}
return risks;
}
function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk {
return {
key,
title: item.title,
detail,
itemId: item.id,
itemType: workItemToSilentItemType(item),
};
}
function shouldNeedProgress(
item: WorkItem,
touchedTodayIds: Set<string>,
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): boolean {
if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false;
const latestTouchAt = latestIso([
getLatestItemTouchAt(item),
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
return daysSince(latestTouchAt, now) >= 3;
}
function isActiveUnfinished(item: WorkItem): boolean {
return !item.completed && ACTIVE_STATUSES.has(item.status);
}
function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] {
if (item.type === 'devTask') return 'dev_task';
if (item.type === 'testCase') return 'test_case';
if (item.type === 'bug') return 'bug';
return 'version';
}
function getLatestItemTouchAt(item: WorkItem): string | undefined {
const raw = (item.raw ?? {}) as unknown as Record<string, unknown>;
const extra = item.extra ?? {};
return latestIso([
asString(raw.updatedAt),
asString(raw.completedAt),
asString(raw.actualEndAt),
asString(raw.resolvedAt),
asString(raw.closedAt),
asString(raw.actualStartAt),
asString(raw.startedAt),
asString(raw.createdAt),
asString(extra.completedAt),
asString(extra.actualEndAt),
asString(extra.actualStartAt),
asString(extra.startedAt),
asString(extra.startTime),
]);
}
function latestIso(values: Array<string | undefined>): string | undefined {
let latest: string | undefined;
let latestTime = Number.NEGATIVE_INFINITY;
for (const value of values) {
const date = parseDate(value);
if (!date) continue;
const time = date.getTime();
if (time > latestTime) {
latest = value;
latestTime = time;
}
}
return latest;
}
function daysSince(value: string | undefined, now: Date): number {
const date = parseDate(value);
if (!date) return Number.POSITIVE_INFINITY;
return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000);
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
return {
versionId: 'ver-1',
date: '2026-06-27',
riskScore: 38,
riskLevel: 'attention',
openBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-06-27T01:00:00.000Z',
...patch,
};
}
test('summarizeRiskTrend detects continuous rising risk across three snapshots', () => {
const trend = summarizeRiskTrend([
snapshot({ riskScore: 71, createdAt: '2026-06-29T01:00:00.000Z', date: '2026-06-29' }),
snapshot({ riskScore: 38, createdAt: '2026-06-27T01:00:00.000Z', date: '2026-06-27' }),
snapshot({ riskScore: 52, createdAt: '2026-06-28T01:00:00.000Z', date: '2026-06-28' }),
]);
assert.equal(trend.direction, 'up');
assert.equal(trend.delta, 33);
assert.equal(trend.pattern, 'continuous_rising');
});
test('buildRiskSignature changes when score and bug counts change', () => {
const base = buildRiskSignature(snapshot({ riskScore: 52, openBugCount: 1 }));
const changed = buildRiskSignature(snapshot({ riskScore: 71, openBugCount: 2 }));
assert.notEqual(base, changed);
});

View File

@@ -13,3 +13,81 @@ export interface XiaobaoRiskSnapshot {
confidence: number; confidence: number;
createdAt: string; createdAt: string;
} }
export interface RiskTrendSummary {
direction: 'up' | 'down' | 'flat' | 'unknown';
delta: number;
summary: string;
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
}
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
if (sorted.length < 2) {
return { direction: 'unknown', delta: 0, summary: 'No historical risk snapshots yet.', pattern: 'unknown' };
}
const first = sorted[0];
const latest = sorted[sorted.length - 1];
const delta = clampScore(latest.riskScore) - clampScore(first.riskScore);
const recentThree = sorted.slice(-3);
if (recentThree.length === 3) {
const [a, b, c] = recentThree.map((snapshot) => clampScore(snapshot.riskScore));
if (a < b && b < c) {
return {
direction: 'up',
delta,
summary: `Risk rose continuously from ${a} to ${c}.`,
pattern: 'continuous_rising',
};
}
if (a > b && b > c) {
return {
direction: 'down',
delta,
summary: `Risk fell continuously from ${a} to ${c}.`,
pattern: 'continuous_falling',
};
}
}
if (delta >= 10) {
return { direction: 'up', delta, summary: `Risk rose by ${delta} points.`, pattern: 'score_delta' };
}
if (delta <= -10) {
return { direction: 'down', delta, summary: `Risk fell by ${Math.abs(delta)} points.`, pattern: 'score_delta' };
}
return { direction: 'flat', delta, summary: 'Risk is stable.', pattern: 'stable' };
}
export function summarizeRiskTrendWithCurrent(
snapshots: XiaobaoRiskSnapshot[] = [],
current: XiaobaoRiskSnapshot,
): RiskTrendSummary {
return summarizeRiskTrend([...snapshots, current]);
}
export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
return [
snapshot.versionId,
snapshot.date,
clampScore(snapshot.riskScore),
snapshot.riskLevel,
snapshot.forecastReleaseDate ?? '',
snapshot.openBugCount,
snapshot.failedTestCount,
snapshot.blockedCount,
snapshot.silentRiskCount,
clampScore(snapshot.confidence),
].join('|');
}
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
return Number.isFinite(date) ? date : 0;
}
function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}

View File

@@ -4,6 +4,7 @@ import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
import type { TestCase } from './test-case'; import type { TestCase } from './test-case';
import { getTestCaseEstimateHours } from './test-case'; import { getTestCaseEstimateHours } from './test-case';
import type { VersionDailyEvidence } from './xiaobao-risk-evidence'; import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import { WORK_HOURS, addWorkHours } from './work-hours'; import { WORK_HOURS, addWorkHours } from './work-hours';
export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
@@ -72,7 +73,7 @@ export interface CalcXiaobaoVersionRiskInput {
testCases?: TestCase[]; testCases?: TestCase[];
bugs?: Bug[]; bugs?: Bug[];
now?: Date; now?: Date;
snapshots?: Array<{ riskScore: number; createdAt?: string; date?: string }>; snapshots?: XiaobaoRiskSnapshot[];
silentRisks?: SilentRisk[]; silentRisks?: SilentRisk[];
dailyEvidence?: VersionDailyEvidence; dailyEvidence?: VersionDailyEvidence;
recentActivityCount?: number; recentActivityCount?: number;
@@ -197,7 +198,19 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0; const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk); const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
const confidence = calcConfidence(input, devTasks, testCases, bugs); const confidence = calcConfidence(input, devTasks, testCases, bugs);
const trend = calcTrend(riskScore, input.snapshots ?? []); const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], {
versionId: input.version.id,
date: now.toISOString().slice(0, 10),
riskScore,
riskLevel,
forecastReleaseDate,
openBugCount: signals.criticalBugCount,
failedTestCount: signals.failedTestCount,
blockedCount: signals.blockedCount,
silentRiskCount: signals.silentRiskCount,
confidence,
createdAt: now.toISOString(),
});
return { return {
versionId: input.version.id, versionId: input.version.id,
@@ -280,27 +293,6 @@ function hasEstimate(item: DevTask | TestCase | Bug): boolean {
|| (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0); || (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0);
} }
function calcTrend(
currentScore: number,
snapshots: Array<{ riskScore: number; createdAt?: string; date?: string }>,
): XiaobaoVersionRisk['trend'] {
if (snapshots.length === 0) {
return { direction: 'unknown', delta: 0, summary: '暂无历史快照。' };
}
const latest = [...snapshots].sort((a, b) => {
const aTime = parseDate(a.createdAt ?? a.date)?.getTime() ?? 0;
const bTime = parseDate(b.createdAt ?? b.date)?.getTime() ?? 0;
return bTime - aTime;
})[0];
const delta = clampScore(currentScore) - clampScore(latest.riskScore);
const direction = Math.abs(delta) < 5 ? 'flat' : delta > 0 ? 'up' : 'down';
const summary = direction === 'flat'
? '风险基本持平。'
: direction === 'up'
? `风险上升 ${delta} 分。`
: `风险下降 ${Math.abs(delta)} 分。`;
return { direction, delta, summary };
}
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel { function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
if (confidence >= 75) return 'high'; if (confidence >= 75) return 'high';