fix(小宝预警): 稳定风险证据与建议更新
关键改动: - 优化风险证据的日报工时与静默风险计算 - 稳定 AI 触发签名,避免证据细节变化造成重复请求 - 调整预警建议更新状态与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -133,7 +133,8 @@ function XiaobaoWarningContent() {
|
|||||||
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
})), [insights, pendingInsightKeys, risks]);
|
pendingInsightAttempts: insightRequestAttempts,
|
||||||
|
})), [insightRequestAttempts, insights, pendingInsightKeys, risks]);
|
||||||
|
|
||||||
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
||||||
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
||||||
|
|||||||
@@ -429,6 +429,65 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getReusableInsight keeps cached insight when only daily report evidence details change', () => {
|
||||||
|
const cachedRisk = risk({
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
riskScore: 82,
|
||||||
|
confidence: 70,
|
||||||
|
signals: { ...risk().signals, failedTestCount: 2, silentRiskCount: 1, daysToExpectedRelease: 1 },
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-old', title: 'Progress', summary: 'Worked on checkout flow.', occurredAt: '2026-06-30T03:00:00.000Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 2,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 1,
|
||||||
|
lastActivityAt: '2026-06-30T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
currentSnapshot: snapshot({ riskScore: 82, failedTestCount: 2, silentRiskCount: 1 }),
|
||||||
|
});
|
||||||
|
const current = risk({
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
riskScore: 82,
|
||||||
|
confidence: 70,
|
||||||
|
signals: { ...risk().signals, failedTestCount: 2, silentRiskCount: 1, daysToExpectedRelease: 1 },
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-new', title: 'Progress', summary: 'Updated checkout flow and added handoff notes.', occurredAt: '2026-07-01T04:00:00.000Z' },
|
||||||
|
{ id: 'ev-new-2', title: 'Progress', summary: 'Confirmed QA scope.', occurredAt: '2026-07-01T05:00:00.000Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [
|
||||||
|
{ id: 'note-new', title: 'Note', summary: 'Next work starts tomorrow morning.', occurredAt: '2026-07-01T06:00:00.000Z' },
|
||||||
|
],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 6,
|
||||||
|
totalActivityCount: 3,
|
||||||
|
todayActualHours: 4,
|
||||||
|
lastActivityAt: '2026-07-01T06:00:00.000Z',
|
||||||
|
},
|
||||||
|
currentSnapshot: snapshot({ riskScore: 82, failedTestCount: 2, silentRiskCount: 1 }),
|
||||||
|
});
|
||||||
|
const cached = insight({
|
||||||
|
riskSignature: buildRiskInsightSignature(cachedRisk),
|
||||||
|
insight: { ...insight().insight, summary: 'Cached advice should remain visible.' },
|
||||||
|
generatedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getReusableInsight([cached], current)?.insight.summary, 'Cached advice should remain visible.');
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 70 }), new Date('2026-07-01T09:00:00.000Z')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('getReusableInsight does not reuse cached insight when core risk facts change', () => {
|
test('getReusableInsight does not reuse cached insight when core risk facts change', () => {
|
||||||
const cachedRisk = risk({
|
const cachedRisk = risk({
|
||||||
riskLevel: 'likely_delayed',
|
riskLevel: 'likely_delayed',
|
||||||
|
|||||||
@@ -370,8 +370,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
|
|||||||
const parsed = JSON.parse(signature) as Record<string, unknown>;
|
const parsed = JSON.parse(signature) as Record<string, unknown>;
|
||||||
const signals = readRecord(parsed.signals);
|
const signals = readRecord(parsed.signals);
|
||||||
const trend = readRecord(parsed.trend);
|
const trend = readRecord(parsed.trend);
|
||||||
const dailyEvidence = readRecord(parsed.dailyEvidence);
|
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
versionId: readString(parsed.versionId),
|
versionId: readString(parsed.versionId),
|
||||||
riskScore: normalizeInteger(parsed.riskScore),
|
riskScore: normalizeInteger(parsed.riskScore),
|
||||||
@@ -394,15 +392,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
|
|||||||
},
|
},
|
||||||
reasons: normalizeCompatibilityReasons(parsed.reasons),
|
reasons: normalizeCompatibilityReasons(parsed.reasons),
|
||||||
silentRisks: normalizeCompatibilitySilentRisks(parsed.silentRisks),
|
silentRisks: normalizeCompatibilitySilentRisks(parsed.silentRisks),
|
||||||
dailyEvidence: {
|
|
||||||
todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries),
|
|
||||||
todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress),
|
|
||||||
todayCreations: normalizeCompatibilityEvidence(dailyEvidence.todayCreations),
|
|
||||||
todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks),
|
|
||||||
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
|
|
||||||
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
|
|
||||||
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -430,16 +419,6 @@ function normalizeCompatibilitySilentRisks(rows: unknown) {
|
|||||||
.sort(compareSignatureRows);
|
.sort(compareSignatureRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCompatibilityEvidence(rows: unknown) {
|
|
||||||
return asRecordArray(rows)
|
|
||||||
.map((row) => ({
|
|
||||||
id: readString(row.id),
|
|
||||||
title: readString(row.title),
|
|
||||||
summary: readString(row.summary),
|
|
||||||
}))
|
|
||||||
.sort(compareSignatureRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecordArray(rows: unknown): Array<Record<string, unknown>> {
|
function asRecordArray(rows: unknown): Array<Record<string, unknown>> {
|
||||||
return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : [];
|
return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,134 @@ test('buildVersionDailyEvidence groups version activity and detects no activity/
|
|||||||
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
|
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildVersionDailyEvidence uses plan record work start time for daily hours', () => {
|
||||||
|
const workItems = buildXiaobaoWorkItems({
|
||||||
|
plans: [
|
||||||
|
plan({
|
||||||
|
id: 'plan-cross-day',
|
||||||
|
title: 'Product plan',
|
||||||
|
actualStartAt: '2026-06-30T01:00:00.000Z',
|
||||||
|
createdAt: '2026-06-30T01:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||||
|
requirementVersionMap: new Map(),
|
||||||
|
});
|
||||||
|
const activities: WorkActivity[] = [
|
||||||
|
{
|
||||||
|
id: 'act-plan-record',
|
||||||
|
actorId: 'pm-1',
|
||||||
|
date: '2026-07-01',
|
||||||
|
occurredAt: '2026-07-01T04:00:00.000Z',
|
||||||
|
sourceType: 'version_plan',
|
||||||
|
sourceId: 'plan-cross-day',
|
||||||
|
action: 'version_plan_requirement_progress',
|
||||||
|
category: 'progress',
|
||||||
|
title: 'Product plan',
|
||||||
|
summary: 'Updated product plan requirement progress',
|
||||||
|
metadata: {
|
||||||
|
requirementId: 'req-1',
|
||||||
|
workStartedAt: '2026-07-01T02:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const evidence = buildVersionDailyEvidence({
|
||||||
|
versionId: 'ver-1',
|
||||||
|
workItems,
|
||||||
|
activities,
|
||||||
|
worklogs: [],
|
||||||
|
now: new Date('2026-07-01T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(evidence.todayActualHours, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVersionDailyEvidence uses previous progress next start time for dev task daily hours', () => {
|
||||||
|
const workItems = buildXiaobaoWorkItems({
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
id: 'dev-cross-day-progress',
|
||||||
|
title: 'Cross-day development',
|
||||||
|
actualStartAt: '2026-07-01T01:30:00.000Z',
|
||||||
|
updatedAt: '2026-07-02T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||||
|
requirementVersionMap: new Map([['req-1', 'ver-1']]),
|
||||||
|
});
|
||||||
|
const activities: WorkActivity[] = [
|
||||||
|
{
|
||||||
|
id: 'act-dev-progress-day-1',
|
||||||
|
actorId: 'dev-1',
|
||||||
|
date: '2026-07-01',
|
||||||
|
occurredAt: '2026-07-01T10:00:00.000Z',
|
||||||
|
sourceType: 'dev_task',
|
||||||
|
sourceId: 'dev-cross-day-progress',
|
||||||
|
action: 'progress_note_added',
|
||||||
|
category: 'note',
|
||||||
|
title: 'Cross-day development',
|
||||||
|
summary: 'Updated today progress',
|
||||||
|
metadata: {
|
||||||
|
note: 'Finished the first part',
|
||||||
|
nextStartAt: '2026-07-02T01:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-dev-progress-day-2',
|
||||||
|
actorId: 'dev-1',
|
||||||
|
date: '2026-07-02',
|
||||||
|
occurredAt: '2026-07-02T10:00:00.000Z',
|
||||||
|
sourceType: 'dev_task',
|
||||||
|
sourceId: 'dev-cross-day-progress',
|
||||||
|
action: 'progress_note_added',
|
||||||
|
category: 'note',
|
||||||
|
title: 'Cross-day development',
|
||||||
|
summary: 'Updated today progress',
|
||||||
|
metadata: {
|
||||||
|
note: 'Finished the second part',
|
||||||
|
nextStartAt: '2026-07-03T01:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const evidence = buildVersionDailyEvidence({
|
||||||
|
versionId: 'ver-1',
|
||||||
|
workItems,
|
||||||
|
activities,
|
||||||
|
worklogs: [],
|
||||||
|
now: new Date('2026-07-02T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(evidence.todayActualHours, 7.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVersionDailyEvidence does not emit Infinity-day silent risk details', () => {
|
||||||
|
const workItems = buildXiaobaoWorkItems({
|
||||||
|
plans: [
|
||||||
|
plan({
|
||||||
|
id: 'plan-no-evidence',
|
||||||
|
title: 'Plan without evidence',
|
||||||
|
createdAt: '2026-06-20T01:00:00.000Z',
|
||||||
|
actualStartAt: '2026-06-20T01:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||||
|
requirementVersionMap: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evidence = buildVersionDailyEvidence({
|
||||||
|
versionId: 'ver-1',
|
||||||
|
workItems,
|
||||||
|
activities: [],
|
||||||
|
worklogs: [],
|
||||||
|
now: new Date('2026-06-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok((evidence.silentRisks ?? []).length > 0);
|
||||||
|
assert.equal((evidence.silentRisks ?? []).some((risk) => risk.detail.includes('Infinity')), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildVersionDailyEvidence mirrors daily report timestamp fallback evidence', () => {
|
test('buildVersionDailyEvidence mirrors daily report timestamp fallback evidence', () => {
|
||||||
const workItems = buildXiaobaoWorkItems({
|
const workItems = buildXiaobaoWorkItems({
|
||||||
plans: [
|
plans: [
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type EvidenceDraft = EvidenceItem & {
|
|||||||
sourceType: WorkActivity['sourceType'];
|
sourceType: WorkActivity['sourceType'];
|
||||||
action: WorkActivity['action'];
|
action: WorkActivity['action'];
|
||||||
category: WorkActivityCategory;
|
category: WorkActivityCategory;
|
||||||
|
metadata?: WorkActivity['metadata'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
||||||
@@ -219,9 +220,7 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput)
|
|||||||
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
||||||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||||
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
|
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
|
||||||
const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId)))
|
const activityHours = calcActivityHoursForDate(activityEvidence, versionActivities, itemMap, worklogTaskIds, today, now);
|
||||||
.filter((sourceId) => !worklogTaskIds.has(sourceId))
|
|
||||||
.reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0);
|
|
||||||
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
|
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -262,6 +261,7 @@ function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
|
|||||||
sourceType: activity.sourceType,
|
sourceType: activity.sourceType,
|
||||||
action: activity.action,
|
action: activity.action,
|
||||||
category: activity.category,
|
category: activity.category,
|
||||||
|
metadata: activity.metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +287,7 @@ function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
summary: item.summary,
|
summary: item.summary,
|
||||||
occurredAt: item.occurredAt,
|
occurredAt: item.occurredAt,
|
||||||
|
metadata: item.metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,20 +428,20 @@ function buildSilentRisks(
|
|||||||
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
|
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
|
||||||
]);
|
]);
|
||||||
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
|
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
|
||||||
const evidenceDays = daysSince(latestEvidenceAt, now);
|
const evidenceDays = daysSince(latestEvidenceAt ?? latestUpdateAt, now);
|
||||||
const updateDays = daysSince(latestUpdateAt, now);
|
const updateDays = daysSince(latestUpdateAt, now);
|
||||||
const touchDays = daysSince(latestTouchAt, now);
|
const touchDays = daysSince(latestTouchAt, now);
|
||||||
|
|
||||||
if (updateDays >= 5) {
|
if (Number.isFinite(updateDays) && updateDays >= 5) {
|
||||||
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
|
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
|
||||||
}
|
}
|
||||||
if (evidenceDays >= 8) {
|
if (Number.isFinite(evidenceDays) && evidenceDays >= 8) {
|
||||||
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
|
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
|
||||||
}
|
}
|
||||||
if (evidenceDays >= 4) {
|
if (Number.isFinite(evidenceDays) && evidenceDays >= 4) {
|
||||||
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
|
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
|
||||||
}
|
}
|
||||||
if (isActiveUnfinished(item) && touchDays >= 3) {
|
if (isActiveUnfinished(item) && Number.isFinite(touchDays) && touchDays >= 3) {
|
||||||
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
|
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,25 +505,155 @@ function getLatestItemTouchAt(item: WorkItem): string | undefined {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
|
function calcActivityHoursForDate(
|
||||||
const interval = getActualInterval(item, now);
|
activities: EvidenceDraft[],
|
||||||
if (!interval) return 0;
|
allActivities: WorkActivity[],
|
||||||
|
itemMap: Map<string, WorkItem>,
|
||||||
|
excludedSourceIds: Set<string>,
|
||||||
|
date: string,
|
||||||
|
now: Date,
|
||||||
|
): number {
|
||||||
|
const recordScopedSourceIds = new Set<string>();
|
||||||
|
const intervals = activities
|
||||||
|
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
|
||||||
|
.map((activity) => {
|
||||||
|
if (!isRecordScopedProgressActivity(activity)) return undefined;
|
||||||
|
recordScopedSourceIds.add(activity.sourceId);
|
||||||
|
const workStartedAt = getRecordScopedWorkStartedAt(activity, allActivities, itemMap, date);
|
||||||
|
if (!workStartedAt) return undefined;
|
||||||
|
return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date);
|
||||||
|
})
|
||||||
|
.filter(isTimeInterval);
|
||||||
|
|
||||||
|
const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId)));
|
||||||
|
const fallbackIntervals = sourceIds
|
||||||
|
.filter((sourceId) => !excludedSourceIds.has(sourceId))
|
||||||
|
.filter((sourceId) => !recordScopedSourceIds.has(sourceId))
|
||||||
|
.map((sourceId) => getWorkItemIntervalForDate(itemMap.get(sourceId), date, now))
|
||||||
|
.filter(isTimeInterval);
|
||||||
|
|
||||||
|
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlanRecordProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action'>): boolean {
|
||||||
|
return activity.sourceType === 'version_plan'
|
||||||
|
&& (activity.action === 'version_plan_requirement_progress'
|
||||||
|
|| activity.action === 'version_plan_research_direction_progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProgressNoteRecordActivity(activity: Pick<WorkActivity, 'action' | 'metadata'>): boolean {
|
||||||
|
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordScopedProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action' | 'metadata'>): boolean {
|
||||||
|
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordScopedWorkStartedAt(
|
||||||
|
activity: EvidenceDraft,
|
||||||
|
allActivities: WorkActivity[],
|
||||||
|
itemMap: Map<string, WorkItem>,
|
||||||
|
date: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (isPlanRecordProgressActivity(activity)) {
|
||||||
|
return asString(activity.metadata?.workStartedAt)
|
||||||
|
?? getPreviousRecordNextStartAt(activity, allActivities)
|
||||||
|
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProgressNoteRecordActivity(activity)) {
|
||||||
|
return getPreviousRecordNextStartAt(activity, allActivities)
|
||||||
|
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousRecordNextStartAt(activity: Pick<WorkActivity, 'sourceId' | 'occurredAt'>, allActivities: WorkActivity[]): string | undefined {
|
||||||
|
const currentTime = new Date(activity.occurredAt).getTime();
|
||||||
|
if (!Number.isFinite(currentTime)) return undefined;
|
||||||
|
|
||||||
|
return [...allActivities]
|
||||||
|
.filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt)
|
||||||
|
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
|
||||||
|
.map((item) => asString(item.metadata?.nextStartAt))
|
||||||
|
.find((nextStartAt) => {
|
||||||
|
if (!nextStartAt) return false;
|
||||||
|
const startTime = new Date(nextStartAt).getTime();
|
||||||
|
return Number.isFinite(startTime) && startTime < currentTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined {
|
||||||
|
if (!item) return undefined;
|
||||||
|
const interval = getActualInterval(item, new Date());
|
||||||
|
if (!interval) return undefined;
|
||||||
|
if (!interval.start || !isWithinLocalDate(interval.start, date)) return undefined;
|
||||||
|
return interval.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityIntervalForDate(
|
||||||
|
startAt: string,
|
||||||
|
occurredAt: string,
|
||||||
|
date: string,
|
||||||
|
): { start: number; end: number } | undefined {
|
||||||
const day = getLocalDateBounds(date);
|
const day = getLocalDateBounds(date);
|
||||||
if (!day) return 0;
|
if (!day) return undefined;
|
||||||
|
|
||||||
const start = new Date(interval.start).getTime();
|
const start = new Date(startAt).getTime();
|
||||||
const end = new Date(interval.end).getTime();
|
const end = new Date(occurredAt).getTime();
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
|
||||||
|
|
||||||
const overlapStart = Math.max(start, day.start.getTime());
|
const overlapStart = Math.max(start, day.start.getTime());
|
||||||
const overlapEnd = Math.min(end, day.end.getTime());
|
const overlapEnd = Math.min(end, day.end.getTime());
|
||||||
if (overlapEnd <= overlapStart) return 0;
|
if (overlapEnd <= overlapStart) return undefined;
|
||||||
|
|
||||||
return calcActualElapsedHours(
|
return { start: overlapStart, end: overlapEnd };
|
||||||
new Date(overlapStart).toISOString(),
|
}
|
||||||
new Date(overlapEnd).toISOString(),
|
|
||||||
);
|
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
|
||||||
|
const interval = getActualInterval(item, now);
|
||||||
|
if (!interval) return undefined;
|
||||||
|
|
||||||
|
const day = getLocalDateBounds(date);
|
||||||
|
if (!day) return undefined;
|
||||||
|
|
||||||
|
const start = new Date(interval.start).getTime();
|
||||||
|
const end = new Date(interval.end).getTime();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
|
||||||
|
|
||||||
|
const overlapStart = Math.max(start, day.start.getTime());
|
||||||
|
const overlapEnd = Math.min(end, day.end.getTime());
|
||||||
|
if (overlapEnd <= overlapStart) return undefined;
|
||||||
|
|
||||||
|
return { start: overlapStart, end: overlapEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
|
||||||
|
return Boolean(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
|
||||||
|
if (intervals.length === 0) return 0;
|
||||||
|
|
||||||
|
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
||||||
|
const merged: Array<{ start: number; end: number }> = [];
|
||||||
|
let current = { ...sorted[0] };
|
||||||
|
|
||||||
|
for (const interval of sorted.slice(1)) {
|
||||||
|
if (interval.start <= current.end) {
|
||||||
|
current.end = Math.max(current.end, interval.end);
|
||||||
|
} else {
|
||||||
|
merged.push(current);
|
||||||
|
current = { ...interval };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push(current);
|
||||||
|
|
||||||
|
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
|
||||||
|
new Date(interval.start).toISOString(),
|
||||||
|
new Date(interval.end).toISOString(),
|
||||||
|
), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one i
|
|||||||
assert.equal(result.aiInsight?.summary, '旧的小宝建议');
|
assert.equal(result.aiInsight?.summary, '旧的小宝建议');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('attachXiaobaoRiskSuggestion hides stale pending update notices', () => {
|
||||||
|
const previous = risk({ riskScore: 52, riskLevel: 'attention' });
|
||||||
|
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
||||||
|
const pendingKey = buildXiaobaoRiskInsightPendingKey(current);
|
||||||
|
|
||||||
|
const result = attachXiaobaoRiskSuggestion(current, {
|
||||||
|
insights: [insight(previous)],
|
||||||
|
pendingInsightKeys: [pendingKey],
|
||||||
|
pendingInsightAttempts: { [pendingKey]: '2026-06-30T10:00:00.000Z' },
|
||||||
|
now: new Date('2026-06-30T10:04:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.aiInsightSource, 'previous_ai');
|
||||||
|
assert.equal(result.aiInsightUpdating, false);
|
||||||
|
assert.equal(result.aiInsight?.summary, insight(previous).insight.summary);
|
||||||
|
});
|
||||||
|
|
||||||
test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signature matches', () => {
|
test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signature matches', () => {
|
||||||
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
||||||
|
|
||||||
@@ -172,3 +189,49 @@ test('buildXiaobaoRiskInsightPendingKey stays stable for refresh-only risk drift
|
|||||||
|
|
||||||
assert.equal(buildXiaobaoRiskInsightPendingKey(before), buildXiaobaoRiskInsightPendingKey(after));
|
assert.equal(buildXiaobaoRiskInsightPendingKey(before), buildXiaobaoRiskInsightPendingKey(after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildXiaobaoRiskInsightPendingKey stays stable when only daily report evidence details change', () => {
|
||||||
|
const before = risk({
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
riskScore: 82,
|
||||||
|
confidence: 70,
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-old', title: 'Progress', summary: 'Worked on checkout flow.', occurredAt: '2026-06-30T03:00:00.000Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 2,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 1,
|
||||||
|
lastActivityAt: '2026-06-30T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const after = risk({
|
||||||
|
riskLevel: 'at_risk',
|
||||||
|
riskScore: 82,
|
||||||
|
confidence: 70,
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-new', title: 'Progress', summary: 'Updated checkout flow and added handoff notes.', occurredAt: '2026-07-01T04:00:00.000Z' },
|
||||||
|
{ id: 'ev-new-2', title: 'Progress', summary: 'Confirmed QA scope.', occurredAt: '2026-07-01T05:00:00.000Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [
|
||||||
|
{ id: 'note-new', title: 'Note', summary: 'Next work starts tomorrow morning.', occurredAt: '2026-07-01T06:00:00.000Z' },
|
||||||
|
],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 6,
|
||||||
|
totalActivityCount: 3,
|
||||||
|
todayActualHours: 4,
|
||||||
|
lastActivityAt: '2026-07-01T06:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(buildXiaobaoRiskInsightPendingKey(before), buildXiaobaoRiskInsightPendingKey(after));
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import { sanitizeRiskInsight } from './xiaobao-warning-view';
|
|||||||
interface AttachXiaobaoRiskSuggestionInput {
|
interface AttachXiaobaoRiskSuggestionInput {
|
||||||
insights: XiaobaoRiskInsightCacheItem[];
|
insights: XiaobaoRiskInsightCacheItem[];
|
||||||
pendingInsightKeys: string[];
|
pendingInsightKeys: string[];
|
||||||
|
pendingInsightAttempts?: Record<string, string>;
|
||||||
|
pendingInsightVisibleMs?: number;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PENDING_INSIGHT_VISIBLE_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
|
const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
|
||||||
on_track: '按期',
|
on_track: '按期',
|
||||||
attention: '需关注',
|
attention: '需关注',
|
||||||
@@ -31,7 +35,7 @@ export function attachXiaobaoRiskSuggestion(
|
|||||||
input: AttachXiaobaoRiskSuggestionInput,
|
input: AttachXiaobaoRiskSuggestionInput,
|
||||||
): XiaobaoVersionRisk {
|
): XiaobaoVersionRisk {
|
||||||
const pendingKey = buildXiaobaoRiskInsightPendingKey(risk);
|
const pendingKey = buildXiaobaoRiskInsightPendingKey(risk);
|
||||||
const isUpdating = input.pendingInsightKeys.includes(pendingKey);
|
const isUpdating = isFreshPendingInsight(pendingKey, input);
|
||||||
const reusable = getReusableInsight(input.insights, risk);
|
const reusable = getReusableInsight(input.insights, risk);
|
||||||
if (reusable) {
|
if (reusable) {
|
||||||
return {
|
return {
|
||||||
@@ -60,6 +64,19 @@ export function attachXiaobaoRiskSuggestion(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFreshPendingInsight(pendingKey: string, input: AttachXiaobaoRiskSuggestionInput): boolean {
|
||||||
|
if (!input.pendingInsightKeys.includes(pendingKey)) return false;
|
||||||
|
const requestedAt = input.pendingInsightAttempts?.[pendingKey];
|
||||||
|
if (!requestedAt) return true;
|
||||||
|
|
||||||
|
const requestedTime = new Date(requestedAt).getTime();
|
||||||
|
const nowTime = (input.now ?? new Date()).getTime();
|
||||||
|
if (!Number.isFinite(requestedTime) || !Number.isFinite(nowTime)) return false;
|
||||||
|
|
||||||
|
const visibleMs = input.pendingInsightVisibleMs ?? DEFAULT_PENDING_INSIGHT_VISIBLE_MS;
|
||||||
|
return nowTime - requestedTime < visibleMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRuleBasedRiskInsight(risk: XiaobaoVersionRisk, now = new Date()): XiaobaoRiskInsight {
|
export function buildRuleBasedRiskInsight(risk: XiaobaoVersionRisk, now = new Date()): XiaobaoRiskInsight {
|
||||||
const firstReason = risk.reasons[0];
|
const firstReason = risk.reasons[0];
|
||||||
const why = risk.reasons.length > 0
|
const why = risk.reasons.length > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user