From bf4b32f17d30c466b778756188d337bbb8e26199 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 09:28:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BF=AB=E7=85=A7=E8=8A=82=E6=B5=81=E5=92=8C?= =?UTF-8?q?=20AI=20cooldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 13 +++--- apps/web/lib/xiaobao-risk-ai.test.ts | 59 +++++++++++++++++++++++++ apps/web/lib/xiaobao-risk-ai.ts | 56 +++++++++++++++++++++++ apps/web/lib/xiaobao-risk-trend.test.ts | 34 +++++++++++++- apps/web/lib/xiaobao-risk-trend.ts | 47 ++++++++++++++++++++ docs/decisions.md | 2 + docs/workflow.md | 4 +- 7 files changed, 207 insertions(+), 8 deletions(-) diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 3224624..31c2feb 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -18,9 +18,9 @@ import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; import { flattenVersions } from '@/lib/derive'; import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; -import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai'; +import { buildRiskInsightSignature, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; -import { buildRiskSignature } from '@/lib/xiaobao-risk-trend'; +import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; type RiskFilter = 'all' | 'attention' | 'high'; @@ -128,7 +128,9 @@ function XiaobaoWarningContent() { useEffect(() => { risks.forEach((risk) => { - const snapshot = risk.currentSnapshot; + const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() }; + const previousToday = findLatestDailySnapshot(snapshots, snapshot.versionId, snapshot.date); + if (!shouldSaveRiskSnapshot(snapshot, previousToday)) return; const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`; if (savedSnapshotKeysRef.current.has(key)) return; savedSnapshotKeysRef.current.add(key); @@ -136,13 +138,14 @@ function XiaobaoWarningContent() { savedSnapshotKeysRef.current.delete(key); }); }); - }, [risks, saveSnapshot]); + }, [risks, saveSnapshot, snapshots]); useEffect(() => { risks.forEach((risk) => { if (getReusableInsight(insights, risk)) return; const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); - if (!shouldRequestRiskInsight(risk, previous)) return; + const latestInsight = findLatestRiskInsightForVersion(insights, risk.versionId); + if (!shouldRequestRiskInsightWithCooldown(risk, previous, latestInsight)) return; const signature = buildRiskInsightSignature(risk); const key = `${risk.versionId}:${signature}`; if (requestedInsightKeysRef.current.has(key)) return; diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 7abe5ec..c9d7262 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -5,9 +5,12 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { buildRiskInsightSignature, buildRiskInterpretRequest, + findLatestRiskInsightForVersion, findPreviousRiskSnapshot, shouldRequestRiskInsight, + shouldRequestRiskInsightWithCooldown, } from './xiaobao-risk-ai'; +import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; function snapshot(patch: Partial = {}): XiaobaoRiskSnapshot { return { @@ -69,6 +72,23 @@ function risk(patch: Partial = {}): XiaobaoVersionRisk { } as unknown as XiaobaoVersionRisk; } +function insight(patch: Partial = {}): XiaobaoRiskInsightCacheItem { + return { + versionId: 'ver-1', + riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })), + insight: { + summary: 'Risk needs attention.', + why: ['Risk rose.'], + forecast: 'May slip.', + suggestedActions: ['Confirm scope.'], + ownerHints: ['PM'], + generatedAt: '2026-06-29T08:00:00.000Z', + }, + generatedAt: '2026-06-29T08:00:00.000Z', + ...patch, + }; +} + test('shouldRequestRiskInsight skips on_track', () => { assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false); }); @@ -184,6 +204,45 @@ test('shouldRequestRiskInsight triggers high risk levels', () => { assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), undefined), true); }); +test('findLatestRiskInsightForVersion returns latest generated insight for a version', () => { + const latest = findLatestRiskInsightForVersion([ + insight({ versionId: 'ver-1', generatedAt: '2026-06-29T08:00:00.000Z' }), + insight({ versionId: 'ver-1', generatedAt: '2026-06-29T10:00:00.000Z' }), + insight({ versionId: 'ver-2', generatedAt: '2026-06-29T11:00:00.000Z' }), + ], 'ver-1'); + + assert.equal(latest?.generatedAt, '2026-06-29T10:00:00.000Z'); +}); + +test('shouldRequestRiskInsightWithCooldown skips repeated AI requests inside cooldown', () => { + const current = risk({ riskLevel: 'at_risk', riskScore: 62 }); + const previous = snapshot({ riskScore: 40 }); + const latest = insight({ + generatedAt: '2026-06-29T10:00:00.000Z', + riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 60 })), + }); + + assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), false); +}); + +test('shouldRequestRiskInsightWithCooldown allows AI requests after cooldown', () => { + const current = risk({ riskLevel: 'at_risk', riskScore: 62 }); + const previous = snapshot({ riskScore: 40 }); + const latest = insight({ generatedAt: '2026-06-29T04:00:00.000Z' }); + + assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), true); +}); + +test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level escalates', () => { + const previousInsight = insight({ + generatedAt: '2026-06-29T10:30:00.000Z', + riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })), + }); + const current = risk({ riskLevel: 'blocked', riskScore: 90 }); + + assert.equal(shouldRequestRiskInsightWithCooldown(current, snapshot({ riskScore: 50 }), previousInsight, new Date('2026-06-29T11:00:00.000Z')), true); +}); + test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => { const base = buildRiskInsightSignature(risk({ signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 }, diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index e32d48f..ae5a8ed 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -24,6 +24,14 @@ type RiskInsightPrevious = Pick< const SCORE_TRIGGER_DELTA = 15; const CONFIDENCE_DROP_DELTA = 15; const ONE_DAY_MS = 86_400_000; +const RISK_INSIGHT_COOLDOWN_MS = 6 * 60 * 60 * 1000; +const RISK_LEVEL_RANK: Record = { + on_track: 0, + attention: 1, + at_risk: 2, + likely_delayed: 3, + blocked: 4, +}; export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean { if (current.riskLevel === 'on_track') return false; @@ -57,6 +65,31 @@ export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: ); } +export function findLatestRiskInsightForVersion( + cache: XiaobaoRiskInsightCacheItem[], + versionId: string, +): XiaobaoRiskInsightCacheItem | undefined { + return cache + .filter((item) => item.versionId === versionId) + .sort((a, b) => getTime(b.generatedAt) - getTime(a.generatedAt))[0]; +} + +export function shouldRequestRiskInsightWithCooldown( + current: RiskInsightCurrent, + previous?: RiskInsightPrevious, + latestInsight?: XiaobaoRiskInsightCacheItem, + now: Date = new Date(), +): boolean { + if (!shouldRequestRiskInsight(current, previous)) return false; + if (!latestInsight) return true; + if (isRiskLevelEscalation(current.riskLevel, latestInsight)) return true; + + const generatedAt = getTime(latestInsight.generatedAt); + const nowTime = now.getTime(); + if (!Number.isFinite(generatedAt) || !Number.isFinite(nowTime)) return true; + return nowTime - generatedAt >= RISK_INSIGHT_COOLDOWN_MS; +} + export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { return JSON.stringify({ versionId: risk.versionId, @@ -219,3 +252,26 @@ function compareSignatureRows(a: T, b: T): number { function clampScore(score: number): number { return Math.max(0, Math.min(100, Math.round(score))); } + +function isRiskLevelEscalation( + currentLevel: XiaobaoVersionRisk['riskLevel'], + latestInsight: XiaobaoRiskInsightCacheItem, +): boolean { + const previousLevel = parseRiskLevel(latestInsight.riskSignature); + if (!previousLevel) return false; + return RISK_LEVEL_RANK[currentLevel] > RISK_LEVEL_RANK[previousLevel]; +} + +function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | undefined { + try { + const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoVersionRisk['riskLevel'] }; + return parsed.riskLevel && parsed.riskLevel in RISK_LEVEL_RANK ? parsed.riskLevel : undefined; + } catch { + return undefined; + } +} + +function getTime(value: string): number { + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : Number.NaN; +} diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts index d1459ea..424b754 100644 --- a/apps/web/lib/xiaobao-risk-trend.test.ts +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import type { Bug } from './bug'; import { calcXiaobaoVersionRisk } from './xiaobao-risk'; -import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend'; +import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot, summarizeRiskTrend } from './xiaobao-risk-trend'; import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; function snapshot(patch: Partial): XiaobaoRiskSnapshot { @@ -48,6 +48,38 @@ test('buildRiskSignature changes when critical bug count changes without open bu assert.notEqual(base, changed); }); +test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => { + const latest = findLatestDailySnapshot([ + snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }), + snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 45, createdAt: '2026-06-29T10:00:00.000Z' }), + snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 70, createdAt: '2026-06-28T10:00:00.000Z' }), + snapshot({ versionId: 'ver-2', date: '2026-06-29', riskScore: 90, createdAt: '2026-06-29T11:00:00.000Z' }), + ], 'ver-1', '2026-06-29'); + + assert.equal(latest?.riskScore, 45); +}); + +test('shouldSaveRiskSnapshot skips small same-day changes inside throttle window', () => { + const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' }); + const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:03:00.000Z' }); + + assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:03:00.000Z')), false); +}); + +test('shouldSaveRiskSnapshot saves material signal changes immediately', () => { + const previous = snapshot({ date: '2026-06-29', riskScore: 40, criticalBugCount: 0, createdAt: '2026-06-29T10:00:00.000Z' }); + const current = snapshot({ date: '2026-06-29', riskScore: 41, criticalBugCount: 1, createdAt: '2026-06-29T10:02:00.000Z' }); + + assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:02:00.000Z')), true); +}); + +test('shouldSaveRiskSnapshot saves minor signature changes after throttle window', () => { + const previous = snapshot({ date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T10:00:00.000Z' }); + const current = snapshot({ date: '2026-06-29', riskScore: 43, createdAt: '2026-06-29T10:12:00.000Z' }); + + assert.equal(shouldSaveRiskSnapshot(current, previous, new Date('2026-06-29T10:12:00.000Z')), true); +}); + test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => { const now = new Date('2026-07-02T01:00:00.000Z'); const risk = calcXiaobaoVersionRisk({ diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts index 0e88ff0..a1dfcbe 100644 --- a/apps/web/lib/xiaobao-risk-trend.ts +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -22,6 +22,10 @@ export interface RiskTrendSummary { pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; } +const SNAPSHOT_SAVE_THROTTLE_MS = 10 * 60 * 1000; +const SNAPSHOT_SCORE_DELTA = 5; +const ONE_DAY_MS = 86_400_000; + export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary { const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b)); if (sorted.length < 2) { @@ -85,6 +89,49 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { ].join('|'); } +export function findLatestDailySnapshot( + snapshots: XiaobaoRiskSnapshot[], + versionId: string, + date: string, +): XiaobaoRiskSnapshot | undefined { + return snapshots + .filter((snapshot) => snapshot.versionId === versionId && snapshot.date === date) + .sort((a, b) => getSnapshotTime(b) - getSnapshotTime(a))[0]; +} + +export function shouldSaveRiskSnapshot( + current: XiaobaoRiskSnapshot, + previous?: XiaobaoRiskSnapshot, + now: Date = new Date(), +): boolean { + if (!previous) return true; + if (previous.versionId !== current.versionId || previous.date !== current.date) return true; + if (buildRiskSignature(current) === buildRiskSignature(previous)) return false; + if (hasMaterialSnapshotChange(current, previous)) return true; + + const previousTime = getSnapshotTime(previous); + const nowTime = now.getTime(); + if (!Number.isFinite(previousTime) || !Number.isFinite(nowTime)) return true; + return nowTime - previousTime >= SNAPSHOT_SAVE_THROTTLE_MS; +} + +function hasMaterialSnapshotChange(current: XiaobaoRiskSnapshot, previous: XiaobaoRiskSnapshot): boolean { + if (current.riskLevel !== previous.riskLevel) return true; + if (Math.abs(clampScore(current.riskScore) - clampScore(previous.riskScore)) >= SNAPSHOT_SCORE_DELTA) return true; + if ((current.criticalBugCount ?? 0) !== (previous.criticalBugCount ?? 0)) return true; + if (current.failedTestCount !== previous.failedTestCount) return true; + if (current.blockedCount !== previous.blockedCount) return true; + if (current.silentRiskCount !== previous.silentRiskCount) return true; + return hasForecastShiftedByOneDay(current.forecastReleaseDate, previous.forecastReleaseDate); +} + +function hasForecastShiftedByOneDay(current?: string, previous?: string): boolean { + if (!current && !previous) return false; + if (!current || !previous) return true; + const delta = Math.abs(new Date(current).getTime() - new Date(previous).getTime()); + return Number.isFinite(delta) && delta >= ONE_DAY_MS; +} + function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number { const date = new Date(snapshot.createdAt || snapshot.date).getTime(); return Number.isFinite(date) ? date : 0; diff --git a/docs/decisions.md b/docs/decisions.md index 1de04d4..f6e0158 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -413,6 +413,8 @@ - 小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势、静默风险和证据摘要。 - `on_track` 不触发 AI;`at_risk`、`likely_delayed`、`blocked` 自动触发 AI;`attention` 只有在风险分、趋势、关键 Bug、失败用例、阻塞、静默风险、置信度或预测日期出现明显恶化时触发。 - AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。 +- 快照保存需要节流:同版本同日普通变化 10 分钟内不重复保存;风险等级变化、风险分变化达到阈值、关键 Bug/失败用例/阻塞/静默风险变化或预测日期明显变化时立即保存。 +- AI 解读需要 cooldown:同版本最近 6 小时内已有解读时不重复请求;如果风险等级升级,则允许绕过 cooldown。 - AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。 **理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。 diff --git a/docs/workflow.md b/docs/workflow.md index 3a69e8d..0f47c12 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -233,9 +233,9 @@ Implementation convention: - `xiaobao.warning:manage`:查看所有未结束版本的预警。 - `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。 -页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。 +页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。快照按同版本同日节流保存:重大变化立即保存,普通变化 10 分钟内不重复写入。 -AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 +AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。