diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 51cb798..68e9f7c 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -133,7 +133,8 @@ function XiaobaoWarningContent() { const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, { insights, pendingInsightKeys, - })), [insights, pendingInsightKeys, risks]); + pendingInsightAttempts: insightRequestAttempts, + })), [insightRequestAttempts, insights, pendingInsightKeys, risks]); const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]); const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]); diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 50ebd91..888313a 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -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', () => { const cachedRisk = risk({ riskLevel: 'likely_delayed', diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index 130cd0a..19676d8 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -370,8 +370,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined { const parsed = JSON.parse(signature) as Record; const signals = readRecord(parsed.signals); const trend = readRecord(parsed.trend); - const dailyEvidence = readRecord(parsed.dailyEvidence); - return JSON.stringify({ versionId: readString(parsed.versionId), riskScore: normalizeInteger(parsed.riskScore), @@ -394,15 +392,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined { }, reasons: normalizeCompatibilityReasons(parsed.reasons), 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 { return undefined; @@ -430,16 +419,6 @@ function normalizeCompatibilitySilentRisks(rows: unknown) { .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> { return Array.isArray(rows) ? rows.map(readRecord).filter((row) => Object.keys(row).length > 0) : []; } diff --git a/apps/web/lib/xiaobao-risk-evidence.test.ts b/apps/web/lib/xiaobao-risk-evidence.test.ts index fb44d9f..563ae27 100644 --- a/apps/web/lib/xiaobao-risk-evidence.test.ts +++ b/apps/web/lib/xiaobao-risk-evidence.test.ts @@ -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')); }); +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', () => { const workItems = buildXiaobaoWorkItems({ plans: [ diff --git a/apps/web/lib/xiaobao-risk-evidence.ts b/apps/web/lib/xiaobao-risk-evidence.ts index 4375d01..d0d47ad 100644 --- a/apps/web/lib/xiaobao-risk-evidence.ts +++ b/apps/web/lib/xiaobao-risk-evidence.ts @@ -61,6 +61,7 @@ type EvidenceDraft = EvidenceItem & { sourceType: WorkActivity['sourceType']; action: WorkActivity['action']; category: WorkActivityCategory; + metadata?: WorkActivity['metadata']; }; export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] { @@ -219,9 +220,7 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput) ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId)); - const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId))) - .filter((sourceId) => !worklogTaskIds.has(sourceId)) - .reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0); + const activityHours = calcActivityHoursForDate(activityEvidence, versionActivities, itemMap, worklogTaskIds, today, now); const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0); return { @@ -262,6 +261,7 @@ function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft { sourceType: activity.sourceType, action: activity.action, category: activity.category, + metadata: activity.metadata, }; } @@ -287,6 +287,7 @@ function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity { title: item.title, summary: item.summary, occurredAt: item.occurredAt, + metadata: item.metadata, }; } @@ -427,20 +428,20 @@ function buildSilentRisks( ...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt), ]); const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]); - const evidenceDays = daysSince(latestEvidenceAt, now); + const evidenceDays = daysSince(latestEvidenceAt ?? latestUpdateAt, now); const updateDays = daysSince(latestUpdateAt, 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.`)); } - if (evidenceDays >= 8) { + if (Number.isFinite(evidenceDays) && evidenceDays >= 8) { 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.`)); } - 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.`)); } } @@ -504,25 +505,155 @@ function getLatestItemTouchAt(item: WorkItem): string | undefined { ]); } -function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number { - const interval = getActualInterval(item, now); - if (!interval) return 0; +function calcActivityHoursForDate( + activities: EvidenceDraft[], + allActivities: WorkActivity[], + itemMap: Map, + excludedSourceIds: Set, + date: string, + now: Date, +): number { + const recordScopedSourceIds = new Set(); + 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): boolean { + return activity.sourceType === 'version_plan' + && (activity.action === 'version_plan_requirement_progress' + || activity.action === 'version_plan_research_direction_progress'); +} + +function isProgressNoteRecordActivity(activity: Pick): boolean { + return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt)); +} + +function isRecordScopedProgressActivity(activity: Pick): boolean { + return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity); +} + +function getRecordScopedWorkStartedAt( + activity: EvidenceDraft, + allActivities: WorkActivity[], + itemMap: Map, + 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, 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); - if (!day) return 0; + 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 0; + const start = new Date(startAt).getTime(); + const end = new Date(occurredAt).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 0; + if (overlapEnd <= overlapStart) return undefined; - return calcActualElapsedHours( - new Date(overlapStart).toISOString(), - new Date(overlapEnd).toISOString(), - ); + return { start: overlapStart, end: overlapEnd }; +} + +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 { diff --git a/apps/web/lib/xiaobao-risk-suggestion.test.ts b/apps/web/lib/xiaobao-risk-suggestion.test.ts index 0430283..350d37f 100644 --- a/apps/web/lib/xiaobao-risk-suggestion.test.ts +++ b/apps/web/lib/xiaobao-risk-suggestion.test.ts @@ -103,6 +103,23 @@ test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one i 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', () => { 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)); }); + +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)); +}); diff --git a/apps/web/lib/xiaobao-risk-suggestion.ts b/apps/web/lib/xiaobao-risk-suggestion.ts index 3a348c2..4148355 100644 --- a/apps/web/lib/xiaobao-risk-suggestion.ts +++ b/apps/web/lib/xiaobao-risk-suggestion.ts @@ -11,9 +11,13 @@ import { sanitizeRiskInsight } from './xiaobao-warning-view'; interface AttachXiaobaoRiskSuggestionInput { insights: XiaobaoRiskInsightCacheItem[]; pendingInsightKeys: string[]; + pendingInsightAttempts?: Record; + pendingInsightVisibleMs?: number; now?: Date; } +const DEFAULT_PENDING_INSIGHT_VISIBLE_MS = 3 * 60 * 1000; + const RISK_LEVEL_LABEL: Record = { on_track: '按期', attention: '需关注', @@ -31,7 +35,7 @@ export function attachXiaobaoRiskSuggestion( input: AttachXiaobaoRiskSuggestionInput, ): XiaobaoVersionRisk { const pendingKey = buildXiaobaoRiskInsightPendingKey(risk); - const isUpdating = input.pendingInsightKeys.includes(pendingKey); + const isUpdating = isFreshPendingInsight(pendingKey, input); const reusable = getReusableInsight(input.insights, risk); if (reusable) { 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 { const firstReason = risk.reasons[0]; const why = risk.reasons.length > 0