From dda453ab5d047383ab24bf285631e948d9e35dcd Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:23:04 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E4=BF=AE=E6=AD=A3=E8=B6=8B=E5=8A=BF=E5=BF=AB=E7=85=A7=20Bu?= =?UTF-8?q?g=20=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-trend.test.ts | 42 +++++++++++++++++++++++++ apps/web/lib/xiaobao-risk.ts | 9 ++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts index 2370018..2424c11 100644 --- a/apps/web/lib/xiaobao-risk-trend.test.ts +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import type { Bug } from './bug'; +import { calcXiaobaoVersionRisk } from './xiaobao-risk'; import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend'; import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; @@ -38,3 +40,43 @@ test('buildRiskSignature changes when score and bug counts change', () => { assert.notEqual(base, changed); }); + +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({ + version: { + id: 'ver-1', + name: 'V1.0', + expectedReleaseDate: '2026-07-03T10:00:00.000Z', + members: [{ name: 'Alice' }], + }, + devTasks: [], + testCases: [], + bugs: [bug({ severity: 'major', priority: 'P2' })], + now, + }); + + assert.equal(risk.signals.openBugCount, 1); + assert.equal(risk.signals.criticalBugCount, 0); + assert.equal(risk.currentSnapshot.openBugCount, 1); + assert.equal(buildRiskSignature(risk.currentSnapshot).split('|')[5], '1'); +}); + +function bug(patch: Partial = {}): Bug { + return { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'ver-1', + testCaseId: 'tc-1', + title: 'Non-critical open bug', + description: 'Open but not critical.', + severity: 'major', + priority: 'P2', + reportedBy: 'qa-1', + assigneeId: 'dev-1', + status: 'open', + createdAt: '2026-07-01T09:00:00.000Z', + updatedAt: '2026-07-01T09:00:00.000Z', + ...patch, + }; +} diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 4737997..1716610 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -60,6 +60,7 @@ export interface XiaobaoVersionRisk { silentRisks: SilentRisk[]; dailyEvidence?: VersionDailyEvidence; signals: XiaobaoRiskSignals; + currentSnapshot: XiaobaoRiskSnapshot; trend: { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; @@ -198,19 +199,20 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0; const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk); const confidence = calcConfidence(input, devTasks, testCases, bugs); - const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], { + const currentSnapshot: XiaobaoRiskSnapshot = { versionId: input.version.id, date: now.toISOString().slice(0, 10), riskScore, riskLevel, forecastReleaseDate, - openBugCount: signals.criticalBugCount, + openBugCount: signals.openBugCount, failedTestCount: signals.failedTestCount, blockedCount: signals.blockedCount, silentRiskCount: signals.silentRiskCount, confidence, createdAt: now.toISOString(), - }); + }; + const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], currentSnapshot); return { versionId: input.version.id, @@ -226,6 +228,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao silentRisks, dailyEvidence: input.dailyEvidence, signals, + currentSnapshot, trend, }; } From db72a420f54fb487c1dc9a912a70b8c14ad44294 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:37:39 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E5=A2=9E=E5=8A=A0=E8=B6=8B=E5=8A=BF=E4=B8=8E=E8=A7=A3?= =?UTF-8?q?=E8=AF=BB=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-cache.test.ts | 70 +++++++++++++++++++++++++ apps/web/lib/xiaobao-risk-cache.ts | 47 +++++++++++++++++ apps/web/lib/xiaobao-risk.ts | 2 + apps/web/stores/useXiaobaoRiskStore.ts | 55 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 apps/web/lib/xiaobao-risk-cache.test.ts create mode 100644 apps/web/lib/xiaobao-risk-cache.ts create mode 100644 apps/web/stores/useXiaobaoRiskStore.ts diff --git a/apps/web/lib/xiaobao-risk-cache.test.ts b/apps/web/lib/xiaobao-risk-cache.test.ts new file mode 100644 index 0000000..4fa5c22 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-cache.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; +import { + findCachedInsight, + upsertDailySnapshot, + upsertInsight, + type XiaobaoRiskInsightCacheItem, +} from './xiaobao-risk-cache'; + +const insightA: XiaobaoRiskInsightCacheItem = { + versionId: 'v-1', + riskSignature: 'sig-a', + insight: { + summary: '版本风险上升', + why: ['剩余工作较多'], + forecast: '可能延期 2 天', + suggestedActions: ['压缩低优先级范围'], + ownerHints: ['请项目负责人确认排期'], + generatedAt: '2026-06-29T08:00:00.000Z', + }, + generatedAt: '2026-06-29T08:00:00.000Z', +}; + +test('findCachedInsight returns matching signature only', () => { + const rows: XiaobaoRiskInsightCacheItem[] = [ + insightA, + { ...insightA, versionId: 'v-2', riskSignature: 'sig-a' }, + { ...insightA, versionId: 'v-1', riskSignature: 'sig-b' }, + ]; + + assert.equal(findCachedInsight(rows, 'v-1', 'sig-a'), insightA); + assert.equal(findCachedInsight(rows, 'v-1', 'sig-b')?.versionId, 'v-1'); + assert.equal(findCachedInsight(rows, 'v-2', 'sig-b'), undefined); +}); + +test('upsertDailySnapshot keeps one snapshot per version and date', () => { + const existing: XiaobaoRiskSnapshot = { + versionId: 'v-1', + date: '2026-06-29', + riskScore: 40, + riskLevel: 'attention', + openBugCount: 1, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-06-29T08:00:00.000Z', + }; + const replacement: XiaobaoRiskSnapshot = { ...existing, riskScore: 72, createdAt: '2026-06-29T09:00:00.000Z' }; + const otherDay: XiaobaoRiskSnapshot = { ...existing, date: '2026-06-28', createdAt: '2026-06-28T09:00:00.000Z' }; + + const result = upsertDailySnapshot([existing, otherDay], replacement); + + assert.deepEqual(result, [replacement, otherDay]); +}); + +test('upsertInsight replaces existing version signature pair', () => { + const replacement: XiaobaoRiskInsightCacheItem = { + ...insightA, + insight: { ...insightA.insight, summary: '已重新生成' }, + generatedAt: '2026-06-29T09:00:00.000Z', + }; + const otherSignature: XiaobaoRiskInsightCacheItem = { ...insightA, riskSignature: 'sig-b' }; + const otherVersion: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'v-2' }; + + const result = upsertInsight([insightA, otherSignature, otherVersion], replacement); + + assert.deepEqual(result, [replacement, otherSignature, otherVersion]); +}); diff --git a/apps/web/lib/xiaobao-risk-cache.ts b/apps/web/lib/xiaobao-risk-cache.ts new file mode 100644 index 0000000..691ac44 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-cache.ts @@ -0,0 +1,47 @@ +import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; + +export interface XiaobaoRiskInsight { + summary: string; + why: string[]; + forecast: string; + recommendedReleaseWindow?: string; + suggestedActions: string[]; + ownerHints: string[]; + generatedAt: string; +} + +export interface XiaobaoRiskInsightCacheItem { + versionId: string; + riskSignature: string; + insight: XiaobaoRiskInsight; + generatedAt: string; + providerInfo?: { providerId?: string; model?: string }; +} + +export function findCachedInsight( + rows: XiaobaoRiskInsightCacheItem[], + versionId: string, + riskSignature: string, +): XiaobaoRiskInsightCacheItem | undefined { + return rows.find((row) => row.versionId === versionId && row.riskSignature === riskSignature); +} + +export function upsertInsight( + rows: XiaobaoRiskInsightCacheItem[], + item: XiaobaoRiskInsightCacheItem, +): XiaobaoRiskInsightCacheItem[] { + return [ + item, + ...rows.filter((row) => row.versionId !== item.versionId || row.riskSignature !== item.riskSignature), + ]; +} + +export function upsertDailySnapshot( + rows: XiaobaoRiskSnapshot[], + item: XiaobaoRiskSnapshot, +): XiaobaoRiskSnapshot[] { + return [ + item, + ...rows.filter((row) => row.versionId !== item.versionId || row.date !== item.date), + ]; +} diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 1716610..8197766 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -3,6 +3,7 @@ import type { DevTask } from './dev-task'; import { STATUS_PROGRESS, getEstimateHours } from './dev-task'; import type { TestCase } from './test-case'; import { getTestCaseEstimateHours } from './test-case'; +import type { XiaobaoRiskInsight } from './xiaobao-risk-cache'; import type { VersionDailyEvidence } from './xiaobao-risk-evidence'; import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { WORK_HOURS, addWorkHours } from './work-hours'; @@ -61,6 +62,7 @@ export interface XiaobaoVersionRisk { dailyEvidence?: VersionDailyEvidence; signals: XiaobaoRiskSignals; currentSnapshot: XiaobaoRiskSnapshot; + aiInsight?: XiaobaoRiskInsight; trend: { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts new file mode 100644 index 0000000..3878803 --- /dev/null +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -0,0 +1,55 @@ +'use client'; +import { create } from 'zustand'; +import { loadServerData, saveServerData } from '@/lib/server-data'; +import { + upsertDailySnapshot, + upsertInsight, + type XiaobaoRiskInsightCacheItem, +} from '@/lib/xiaobao-risk-cache'; +import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend'; + +interface XiaobaoRiskState { + snapshots: XiaobaoRiskSnapshot[]; + insights: XiaobaoRiskInsightCacheItem[]; + fetchRiskData: () => Promise; + saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; + saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise; +} + +async function loadSnapshots(): Promise { + try { + const rows = await loadServerData('xiaobao-risk-snapshots'); + return Array.isArray(rows) ? rows : []; + } catch {} + return []; +} + +async function loadInsights(): Promise { + try { + const rows = await loadServerData('xiaobao-risk-insights'); + return Array.isArray(rows) ? rows : []; + } catch {} + return []; +} + +export const useXiaobaoRiskStore = create((set, get) => ({ + snapshots: [], + insights: [], + + fetchRiskData: async () => { + const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]); + set({ snapshots, insights }); + }, + + saveSnapshot: async (item) => { + const snapshots = upsertDailySnapshot(get().snapshots, item); + set({ snapshots }); + await saveServerData('xiaobao-risk-snapshots', snapshots); + }, + + saveInsight: async (item) => { + const insights = upsertInsight(get().insights, item); + set({ insights }); + await saveServerData('xiaobao-risk-insights', insights); + }, +})); From b11830278cfc10b120062758bb81a703135a874f Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:44:59 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E5=90=88=E5=B9=B6=E8=BF=9C=E7=AB=AF=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-cache.test.ts | 33 ++++++++++++++++++ apps/web/lib/xiaobao-risk-cache.ts | 20 +++++++++++ apps/web/stores/useXiaobaoRiskStore.ts | 46 +++++++++++++++++++------ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/xiaobao-risk-cache.test.ts b/apps/web/lib/xiaobao-risk-cache.test.ts index 4fa5c22..a50ff65 100644 --- a/apps/web/lib/xiaobao-risk-cache.test.ts +++ b/apps/web/lib/xiaobao-risk-cache.test.ts @@ -3,6 +3,8 @@ import test from 'node:test'; import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { findCachedInsight, + mergeDailySnapshotCacheForSave, + mergeInsightCacheForSave, upsertDailySnapshot, upsertInsight, type XiaobaoRiskInsightCacheItem, @@ -68,3 +70,34 @@ test('upsertInsight replaces existing version signature pair', () => { assert.deepEqual(result, [replacement, otherSignature, otherVersion]); }); + +test('mergeDailySnapshotCacheForSave preserves remote rows and local-only rows', () => { + const remoteOnly: XiaobaoRiskSnapshot = { + versionId: 'remote-version', + date: '2026-06-29', + riskScore: 30, + riskLevel: 'attention', + openBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + confidence: 90, + createdAt: '2026-06-29T08:00:00.000Z', + }; + const localOnly: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'local-version', riskScore: 50 }; + const item: XiaobaoRiskSnapshot = { ...remoteOnly, versionId: 'current-version', riskScore: 70 }; + + const result = mergeDailySnapshotCacheForSave([localOnly], [remoteOnly], item); + + assert.deepEqual(result, [item, remoteOnly, localOnly]); +}); + +test('mergeInsightCacheForSave preserves remote rows and local-only rows', () => { + const remoteOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'remote-version' }; + const localOnly: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'local-version' }; + const item: XiaobaoRiskInsightCacheItem = { ...insightA, versionId: 'current-version' }; + + const result = mergeInsightCacheForSave([localOnly], [remoteOnly], item); + + assert.deepEqual(result, [item, remoteOnly, localOnly]); +}); diff --git a/apps/web/lib/xiaobao-risk-cache.ts b/apps/web/lib/xiaobao-risk-cache.ts index 691ac44..98f7b9b 100644 --- a/apps/web/lib/xiaobao-risk-cache.ts +++ b/apps/web/lib/xiaobao-risk-cache.ts @@ -45,3 +45,23 @@ export function upsertDailySnapshot( ...rows.filter((row) => row.versionId !== item.versionId || row.date !== item.date), ]; } + +export function mergeDailySnapshotCacheForSave( + localRows: XiaobaoRiskSnapshot[], + remoteRows: XiaobaoRiskSnapshot[], + item: XiaobaoRiskSnapshot, +): XiaobaoRiskSnapshot[] { + const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.date}`)); + const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.date}`)); + return upsertDailySnapshot([...remoteRows, ...localOnly], item); +} + +export function mergeInsightCacheForSave( + localRows: XiaobaoRiskInsightCacheItem[], + remoteRows: XiaobaoRiskInsightCacheItem[], + item: XiaobaoRiskInsightCacheItem, +): XiaobaoRiskInsightCacheItem[] { + const remoteKeys = new Set(remoteRows.map((row) => `${row.versionId}::${row.riskSignature}`)); + const localOnly = localRows.filter((row) => !remoteKeys.has(`${row.versionId}::${row.riskSignature}`)); + return upsertInsight([...remoteRows, ...localOnly], item); +} diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 3878803..2428994 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { loadServerData, saveServerData } from '@/lib/server-data'; import { + mergeDailySnapshotCacheForSave, + mergeInsightCacheForSave, upsertDailySnapshot, upsertInsight, type XiaobaoRiskInsightCacheItem, @@ -11,45 +13,67 @@ import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend'; interface XiaobaoRiskState { snapshots: XiaobaoRiskSnapshot[]; insights: XiaobaoRiskInsightCacheItem[]; + error?: string; fetchRiskData: () => Promise; saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise; } -async function loadSnapshots(): Promise { +async function loadSnapshots(): Promise { try { const rows = await loadServerData('xiaobao-risk-snapshots'); return Array.isArray(rows) ? rows : []; } catch {} - return []; + return null; } -async function loadInsights(): Promise { +async function loadInsights(): Promise { try { const rows = await loadServerData('xiaobao-risk-insights'); return Array.isArray(rows) ? rows : []; } catch {} - return []; + return null; } export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], insights: [], + error: undefined, fetchRiskData: async () => { const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]); - set({ snapshots, insights }); + set({ + ...(snapshots ? { snapshots } : {}), + ...(insights ? { insights } : {}), + error: snapshots === null || insights === null ? '小宝预警缓存加载失败' : undefined, + }); }, saveSnapshot: async (item) => { - const snapshots = upsertDailySnapshot(get().snapshots, item); - set({ snapshots }); - await saveServerData('xiaobao-risk-snapshots', snapshots); + const optimistic = upsertDailySnapshot(get().snapshots, item); + set({ snapshots: optimistic, error: undefined }); + try { + const remote = await loadServerData('xiaobao-risk-snapshots'); + const snapshots = mergeDailySnapshotCacheForSave(optimistic, Array.isArray(remote) ? remote : [], item); + set({ snapshots, error: undefined }); + await saveServerData('xiaobao-risk-snapshots', snapshots); + } catch (error) { + set({ error: '小宝预警快照保存失败' }); + throw error; + } }, saveInsight: async (item) => { - const insights = upsertInsight(get().insights, item); - set({ insights }); - await saveServerData('xiaobao-risk-insights', insights); + const optimistic = upsertInsight(get().insights, item); + set({ insights: optimistic, error: undefined }); + try { + const remote = await loadServerData('xiaobao-risk-insights'); + const insights = mergeInsightCacheForSave(optimistic, Array.isArray(remote) ? remote : [], item); + set({ insights, error: undefined }); + await saveServerData('xiaobao-risk-insights', insights); + } catch (error) { + set({ error: '小宝预警解读保存失败' }); + throw error; + } }, })); From 7b1a48f1f7a80da5dacc486ffb67deed3beeb41e Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:46:54 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E4=B8=B2=E8=A1=8C=E4=BF=9D=E5=AD=98=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/stores/useXiaobaoRiskStore.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 2428994..960350c 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -35,6 +35,9 @@ async function loadInsights(): Promise { return null; } +let snapshotSaveQueue: Promise = Promise.resolve(); +let insightSaveQueue: Promise = Promise.resolve(); + export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], insights: [], @@ -52,11 +55,15 @@ export const useXiaobaoRiskStore = create((set, get) => ({ saveSnapshot: async (item) => { const optimistic = upsertDailySnapshot(get().snapshots, item); set({ snapshots: optimistic, error: undefined }); - try { + const task = snapshotSaveQueue.then(async () => { const remote = await loadServerData('xiaobao-risk-snapshots'); - const snapshots = mergeDailySnapshotCacheForSave(optimistic, Array.isArray(remote) ? remote : [], item); + const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item); set({ snapshots, error: undefined }); await saveServerData('xiaobao-risk-snapshots', snapshots); + }); + snapshotSaveQueue = task.catch(() => undefined); + try { + await task; } catch (error) { set({ error: '小宝预警快照保存失败' }); throw error; @@ -66,11 +73,15 @@ export const useXiaobaoRiskStore = create((set, get) => ({ saveInsight: async (item) => { const optimistic = upsertInsight(get().insights, item); set({ insights: optimistic, error: undefined }); - try { + const task = insightSaveQueue.then(async () => { const remote = await loadServerData('xiaobao-risk-insights'); - const insights = mergeInsightCacheForSave(optimistic, Array.isArray(remote) ? remote : [], item); + const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item); set({ insights, error: undefined }); await saveServerData('xiaobao-risk-insights', insights); + }); + insightSaveQueue = task.catch(() => undefined); + try { + await task; } catch (error) { set({ error: '小宝预警解读保存失败' }); throw error; From 064617aba62c9b2f58f80a05e47e725e7f0687d5 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:59:11 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E5=A2=9E=E5=8A=A0=20AI=20=E9=A3=8E=E9=99=A9=E8=A7=A3?= =?UTF-8?q?=E8=AF=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/modules/ai/ai.controller.ts | 13 +- apps/server/src/modules/ai/ai.service.spec.ts | 219 ++++++++++++++++++ apps/server/src/modules/ai/ai.service.ts | 123 ++++++++++ .../src/modules/ai/dto/risk-interpret.dto.ts | 142 ++++++++++++ .../src/modules/ai/prompts/risk-interpret.ts | 32 +++ packages/shared/src/agent.ts | 68 ++++++ 6 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/modules/ai/dto/risk-interpret.dto.ts create mode 100644 apps/server/src/modules/ai/prompts/risk-interpret.ts diff --git a/apps/server/src/modules/ai/ai.controller.ts b/apps/server/src/modules/ai/ai.controller.ts index 40a8933..4f43c7a 100644 --- a/apps/server/src/modules/ai/ai.controller.ts +++ b/apps/server/src/modules/ai/ai.controller.ts @@ -1,7 +1,13 @@ import { Body, Controller, Post } from '@nestjs/common'; import { AiService } from './ai.service'; import { DecomposeDto } from './dto/decompose.dto'; -import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared'; +import { RiskInterpretDto } from './dto/risk-interpret.dto'; +import type { + AgentDecomposeResponse, + AgentDecomposeError, + AgentRiskInterpretResponse, + AgentRiskInterpretError, +} from '@ftb/shared'; @Controller('ai') export class AiController { @@ -11,4 +17,9 @@ export class AiController { async decompose(@Body() dto: DecomposeDto): Promise { return this.aiService.decompose(dto); } + + @Post('risk-interpret') + async interpretRisk(@Body() dto: RiskInterpretDto): Promise { + return this.aiService.interpretRisk(dto); + } } diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 7e57b79..957ff29 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -1,11 +1,230 @@ import { AiService } from './ai.service'; import type { AiGatewayService } from './ai-gateway.service'; import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; +import { RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { RiskInterpretDto } from './dto/risk-interpret.dto'; +import type { AgentRiskInterpretRequest } from '@ftb/shared'; const missingToolUseMessage = 'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; +function createRiskRequest(patch: Partial = {}): AgentRiskInterpretRequest { + return { + versionId: 'ver-1', + versionName: 'V1.0', + riskScore: 70, + riskLevel: 'at_risk', + delayDays: 1, + confidence: 80, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 3, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + trendSummary: 'risk rising', + reasons: [{ key: 'critical_bug', label: 'Critical Bug', severity: 'critical', detail: '3 P1 bugs' }], + silentRisks: [], + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayRisks: ['tests failed'], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + }, + ...patch, + }; +} + describe('AiService', () => { + it('interprets version risk through the active provider', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: { + summary: '预计延期', + why: ['P1 Bug 增加'], + forecast: '预计 2026-07-04 可发', + recommendedReleaseWindow: '2026-07-04 之后', + suggestedActions: ['优先修复 P1 Bug'], + ownerHints: ['测试负责人同步失败用例'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }); + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk({ + versionId: 'ver-1', + versionName: 'V1.0', + riskScore: 70, + riskLevel: 'at_risk', + delayDays: 1, + confidence: 80, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 3, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + trendSummary: '风险上升', + reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }], + silentRisks: [], + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayRisks: ['测试不通过'], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.result.summary).toBe('预计延期'); + expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ + forceTool: true, + maxTokens: 3000, + tool: expect.objectContaining({ name: 'submit_risk_interpretation' }), + })); + expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升'); + }); + + it('returns PARSE_ERROR when risk provider returns malformed payload', async () => { + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ + callTool: jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: null, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }), + }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' }); + }); + + it('returns PARSE_ERROR when risk provider returns the wrong tool name', async () => { + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ + callTool: jest.fn().mockResolvedValue({ + toolName: 'submit_decompose', + toolInput: { + summary: 'wrong tool', + why: ['bad'], + forecast: 'bad', + suggestedActions: ['bad'], + ownerHints: ['bad'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }), + }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' }); + }); + + it('returns PARSE_ERROR when risk insight arrays contain non-string values', async () => { + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ + callTool: jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: { + summary: 'bad array', + why: [123], + forecast: 'bad', + suggestedActions: ['bad'], + ownerHints: ['bad'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }), + }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' }); + }); + + it('validates risk daily evidence array items as strings', () => { + const dto = plainToInstance(RiskInterpretDto, { + ...createRiskRequest(), + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayRisks: [123], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + }, + }); + + const errors = validateSync(dto); + + expect(JSON.stringify(errors)).toContain('todayRisks'); + }); + + it('requires risk signals and daily evidence objects in DTO validation', () => { + const { signals: _signals, dailyEvidence: _dailyEvidence, ...payload } = createRiskRequest(); + const dto = plainToInstance(RiskInterpretDto, payload); + + const errors = validateSync(dto); + const serialized = JSON.stringify(errors); + + expect(serialized).toContain('signals'); + expect(serialized).toContain('dailyEvidence'); + }); + + it('forbids extra fields in risk interpretation tool schema', () => { + expect((RISK_INTERPRET_TOOL_INPUT_SCHEMA as any).additionalProperties).toBe(false); + }); + + it('returns PARSE_ERROR when provider reports invalid tool arguments', async () => { + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ + callTool: jest.fn().mockRejectedValue(new Error('OpenAI tool_call arguments is not valid JSON')), + }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' }); + }); + it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => { const callTool = jest .fn() diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 655c8f4..c3684ba 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -6,6 +6,12 @@ import { DECOMPOSE_TOOL_DESCRIPTION, DECOMPOSE_TOOL_INPUT_SCHEMA, } from './prompts/decompose'; +import { + RISK_INTERPRET_SYSTEM_PROMPT, + RISK_INTERPRET_TOOL_NAME, + RISK_INTERPRET_TOOL_DESCRIPTION, + RISK_INTERPRET_TOOL_INPUT_SCHEMA, +} from './prompts/risk-interpret'; import { buildPrototypeContext } from './prototype-context'; import type { AgentDecomposeRequest, @@ -13,6 +19,10 @@ import type { AgentDecomposeError, AgentDecomposeResult, AgentDecomposeTarget, + AgentRiskInsight, + AgentRiskInterpretError, + AgentRiskInterpretRequest, + AgentRiskInterpretResponse, } from '@ftb/shared'; const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; @@ -129,6 +139,119 @@ export class AiService { }; } + async interpretRisk(req: AgentRiskInterpretRequest): Promise { + const startedAt = Date.now(); + let provider; + let model: string; + try { + provider = await this.gateway.getActiveProvider(); + model = await this.gateway.getActiveModel(); + } catch (e: any) { + return { + ok: false, + error: e?.message || '未配置 AI 提供商', + code: 'NO_PROVIDER', + }; + } + + try { + const toolResp = await provider.callTool({ + systemPrompt: RISK_INTERPRET_SYSTEM_PROMPT, + userPrompt: `请基于以下小宝预警规则结果生成解释,禁止编造新事实:\n${JSON.stringify(req, null, 2)}`, + tool: { + name: RISK_INTERPRET_TOOL_NAME, + description: RISK_INTERPRET_TOOL_DESCRIPTION, + inputSchema: RISK_INTERPRET_TOOL_INPUT_SCHEMA, + }, + forceTool: true, + maxTokens: 3000, + model, + }); + const result = this.parseRiskInsightToolResponse(toolResp); + if (!result) { + return { + ok: false, + error: 'AI 返回结构不符合预期', + code: 'PARSE_ERROR', + }; + } + return { + ok: true, + result, + meta: { + model: toolResp.rawModel || model, + inputTokens: toolResp.inputTokens, + outputTokens: toolResp.outputTokens, + durationMs: Date.now() - startedAt, + }, + }; + } catch (e: any) { + if (this.isProviderParseError(e)) { + return { + ok: false, + error: e?.message || 'AI 返回结构不符合预期', + code: 'PARSE_ERROR', + }; + } + this.logger.error(`AI 风险解读调用失败: ${e.message}`); + return { + ok: false, + error: `AI 服务调用失败:${e.message}`, + code: 'API_ERROR', + }; + } + } + + private parseRiskInsightToolResponse(toolResp: any): AgentRiskInsight | null { + if (!toolResp || toolResp.toolName !== RISK_INTERPRET_TOOL_NAME || !this.isRecord(toolResp.toolInput)) { + return null; + } + + const input = toolResp.toolInput; + const summary = this.readString(input.summary); + const forecast = this.readString(input.forecast); + const generatedAt = this.readString(input.generatedAt); + const why = this.readStringArray(input.why); + const suggestedActions = this.readStringArray(input.suggestedActions); + const ownerHints = this.readStringArray(input.ownerHints); + if (!summary || !forecast || !generatedAt || !why || !suggestedActions || !ownerHints) { + return null; + } + + const insight: AgentRiskInsight = { + summary, + why, + forecast, + suggestedActions, + ownerHints, + generatedAt, + }; + if (input.recommendedReleaseWindow !== undefined) { + const recommendedReleaseWindow = this.readString(input.recommendedReleaseWindow); + if (!recommendedReleaseWindow) return null; + insight.recommendedReleaseWindow = recommendedReleaseWindow; + } + return insight; + } + + private readString(value: unknown): string | null { + return typeof value === 'string' ? value : null; + } + + private readStringArray(value: unknown): string[] | null { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) return null; + return value; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private isProviderParseError(error: any): boolean { + const message = String(error?.message || ''); + return message.includes('tool_call arguments') && message.includes('JSON'); + } + private async fetchPrototype(url: string): Promise { if (!url || !url.startsWith('http')) { throw new Error('原型链接无效'); diff --git a/apps/server/src/modules/ai/dto/risk-interpret.dto.ts b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts new file mode 100644 index 0000000..093eeb0 --- /dev/null +++ b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts @@ -0,0 +1,142 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDefined, IsIn, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +class RiskReasonDto { + @IsString() + key!: string; + + @IsString() + label!: string; + + @IsIn(['low', 'medium', 'high', 'critical']) + severity!: 'low' | 'medium' | 'high' | 'critical'; + + @IsString() + detail!: string; +} + +class SilentRiskDto { + @IsString() + key!: string; + + @IsOptional() + @IsNumber() + days?: number; + + @IsString() + detail!: string; +} + +class RiskDailyEvidenceDto { + @IsArray() + @IsString({ each: true }) + todayDeliveries!: string[]; + + @IsArray() + @IsString({ each: true }) + todayProgress!: string[]; + + @IsArray() + @IsString({ each: true }) + todayRisks!: string[]; + + @IsArray() + @IsString({ each: true }) + progressNotes!: string[]; + + @IsArray() + @IsString({ each: true }) + needsProgressItems!: string[]; + + @IsNumber() + recentActivityCount!: number; + + @IsOptional() + @IsString() + lastActivityAt?: string; +} + +class RiskSignalsDto { + @IsNumber() + unfinishedCount!: number; + + @IsNumber() + openBugCount!: number; + + @IsNumber() + criticalBugCount!: number; + + @IsNumber() + failedTestCount!: number; + + @IsNumber() + blockedCount!: number; + + @IsNumber() + silentRiskCount!: number; + + @IsOptional() + @IsNumber() + daysToExpectedRelease?: number; +} + +export class RiskInterpretDto { + @IsString() + versionId!: string; + + @IsString() + versionName!: string; + + @IsOptional() + @IsString() + productName?: string; + + @IsOptional() + @IsString() + projectName?: string; + + @IsNumber() + riskScore!: number; + + @IsIn(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']) + riskLevel!: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + + @IsOptional() + @IsString() + expectedReleaseDate?: string; + + @IsOptional() + @IsString() + forecastReleaseDate?: string; + + @IsNumber() + delayDays!: number; + + @IsNumber() + confidence!: number; + + @IsDefined() + @IsObject() + @ValidateNested() + @Type(() => RiskSignalsDto) + signals!: RiskSignalsDto; + + @IsString() + trendSummary!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RiskReasonDto) + reasons!: RiskReasonDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SilentRiskDto) + silentRisks!: SilentRiskDto[]; + + @IsDefined() + @IsObject() + @ValidateNested() + @Type(() => RiskDailyEvidenceDto) + dailyEvidence!: RiskDailyEvidenceDto; +} diff --git a/apps/server/src/modules/ai/prompts/risk-interpret.ts b/apps/server/src/modules/ai/prompts/risk-interpret.ts new file mode 100644 index 0000000..7f6216e --- /dev/null +++ b/apps/server/src/modules/ai/prompts/risk-interpret.ts @@ -0,0 +1,32 @@ +export const RISK_INTERPRET_TOOL_NAME = 'submit_risk_interpretation'; +export const RISK_INTERPRET_TOOL_DESCRIPTION = '返回小宝预警的结构化解释'; + +export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的“小宝预警”解释助手。 + +你只能解释系统给出的规则预测结果和证据,不能编造不存在的数据。 +你需要用项目经理能理解的中文说明: +1. 当前能不能按期发版; +2. 为什么有风险; +3. 预计延期或建议发版窗口; +4. 需要优先处理的动作; +5. 哪些负责人或角色需要关注。 + +输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。 +如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。 + +输出必须通过 submit_risk_interpretation 工具返回,不要输出自然语言正文或 Markdown。`; + +export const RISK_INTERPRET_TOOL_INPUT_SCHEMA = { + type: 'object', + properties: { + summary: { type: 'string' }, + why: { type: 'array', items: { type: 'string' } }, + forecast: { type: 'string' }, + recommendedReleaseWindow: { type: 'string' }, + suggestedActions: { type: 'array', items: { type: 'string' } }, + ownerHints: { type: 'array', items: { type: 'string' } }, + generatedAt: { type: 'string' }, + }, + required: ['summary', 'why', 'forecast', 'suggestedActions', 'ownerHints', 'generatedAt'], + additionalProperties: false, +}; diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 8dcaa5c..4676b8f 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -114,6 +114,74 @@ export interface AgentDecomposeError { code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; } +export interface AgentRiskReason { + key: string; + label: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + detail: string; +} + +export interface AgentRiskInterpretRequest { + versionId: string; + versionName: string; + productName?: string; + projectName?: string; + riskScore: number; + riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + expectedReleaseDate?: string | null; + forecastReleaseDate?: string; + delayDays: number; + confidence: number; + signals: { + unfinishedCount: number; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; + }; + trendSummary: string; + reasons: AgentRiskReason[]; + silentRisks: Array<{ key: string; days?: number; detail: string }>; + dailyEvidence: { + todayDeliveries: string[]; + todayProgress: string[]; + todayRisks: string[]; + progressNotes: string[]; + needsProgressItems: string[]; + recentActivityCount: number; + lastActivityAt?: string; + }; +} + +export interface AgentRiskInsight { + summary: string; + why: string[]; + forecast: string; + recommendedReleaseWindow?: string; + suggestedActions: string[]; + ownerHints: string[]; + generatedAt: string; +} + +export interface AgentRiskInterpretResponse { + ok: true; + result: AgentRiskInsight; + meta: { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + }; +} + +export interface AgentRiskInterpretError { + ok: false; + error: string; + code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; +} + /* ───────────────────────── AI Provider 配置 ─────────────────────────── */ export type AiProviderFormat = 'anthropic' | 'openai'; From 94a038494ad16c36106a6c7eece00d7dbaa7ee30 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 19:32:26 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E5=A2=9E=E5=8A=A0=20AI=20=E8=A7=A3=E8=AF=BB=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-ai.test.ts | 245 ++++++++++++++++++++++++ apps/web/lib/xiaobao-risk-ai.ts | 211 ++++++++++++++++++++ apps/web/lib/xiaobao-risk-trend.test.ts | 7 + apps/web/lib/xiaobao-risk-trend.ts | 2 + apps/web/lib/xiaobao-risk.test.ts | 14 ++ apps/web/lib/xiaobao-risk.ts | 8 + 6 files changed, 487 insertions(+) create mode 100644 apps/web/lib/xiaobao-risk-ai.test.ts create mode 100644 apps/web/lib/xiaobao-risk-ai.ts diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts new file mode 100644 index 0000000..288fa56 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { XiaobaoVersionRisk } from './xiaobao-risk'; +import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; +import { + buildRiskInsightSignature, + buildRiskInterpretRequest, + shouldRequestRiskInsight, +} from './xiaobao-risk-ai'; + +function snapshot(patch: Partial = {}): XiaobaoRiskSnapshot { + return { + versionId: 'ver-1', + date: '2026-06-28', + riskScore: 35, + riskLevel: 'attention', + forecastReleaseDate: '2026-07-02T10:00:00.000Z', + openBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-06-28T10:00:00.000Z', + ...patch, + }; +} + +function risk(patch: Partial = {}): XiaobaoVersionRisk { + return { + versionId: 'ver-1', + versionName: 'V1.0', + productName: 'FTB', + projectName: 'PM', + riskScore: 68, + riskLevel: 'attention', + expectedReleaseDate: '2026-07-01T10:00:00.000Z', + confidence: 80, + confidenceLevel: 'high', + forecastReleaseDate: '2026-07-02T10:00:00.000Z', + delayDays: 1, + remainingWorkHours: 12, + reasons: [ + { key: 'failed_test', title: 'Failed tests', detail: '1 test failed.', severity: 'warning', count: 1 }, + { key: 'critical_bug', title: 'Critical bug', detail: 'P1 bug exists.', severity: 'danger', count: 1 }, + ], + silentRisks: [{ key: 'no_update', title: 'No update', detail: 'No update for 5 days.' }], + dailyEvidence: { + todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }], + todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }], + todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }], + progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }], + needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }], + recentActivityCount: 3, + lastActivityAt: '2026-06-29T13:00:00.000Z', + }, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 1, + daysToExpectedRelease: 1, + }, + currentSnapshot: snapshot({ riskScore: 68, openBugCount: 3, failedTestCount: 1, silentRiskCount: 1 }), + trend: { direction: 'up', delta: 33, summary: 'Risk rose by 33 points.', pattern: 'score_delta' }, + ...patch, + } as unknown as XiaobaoVersionRisk; +} + +test('shouldRequestRiskInsight skips on_track', () => { + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false); +}); + +test('shouldRequestRiskInsight does not trigger ordinary attention without a previous worsening signal', () => { + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 42, signals: { ...risk().signals, unfinishedCount: 0, daysToExpectedRelease: 5 } }), undefined), false); +}); + +test('shouldRequestRiskInsight triggers attention when risk score jumps', () => { + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 68 }), snapshot({ riskScore: 35 })), true); +}); + +test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => { + assert.equal( + shouldRequestRiskInsight( + risk({ + riskLevel: 'attention', + riskScore: 52, + signals: { + unfinishedCount: 4, + openBugCount: 3, + criticalBugCount: 3, + failedTestCount: 1, + blockedCount: 1, + silentRiskCount: 1, + daysToExpectedRelease: 1, + }, + }), + snapshot({ riskScore: 50, openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 }), + ), + true, + ); +}); + +test('shouldRequestRiskInsight triggers attention when critical bugs increase even if open bug total is unchanged', () => { + assert.equal( + shouldRequestRiskInsight( + risk({ + riskLevel: 'attention', + riskScore: 45, + signals: { + unfinishedCount: 0, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 5, + }, + }), + snapshot({ riskScore: 44, openBugCount: 2, criticalBugCount: 0 } as Partial), + ), + true, + ); +}); + +test('shouldRequestRiskInsight triggers attention on continuously rising trend', () => { + assert.equal( + shouldRequestRiskInsight( + risk({ + riskLevel: 'attention', + riskScore: 46, + signals: { + unfinishedCount: 0, + openBugCount: 0, + criticalBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 5, + }, + trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' }, + }), + snapshot({ riskScore: 38 }), + ), + true, + ); +}); + +test('shouldRequestRiskInsight triggers attention on continuously rising trend without previous snapshot', () => { + assert.equal( + shouldRequestRiskInsight( + risk({ + riskLevel: 'attention', + riskScore: 46, + signals: { + unfinishedCount: 0, + openBugCount: 0, + criticalBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 5, + }, + trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' }, + }), + undefined, + ), + true, + ); +}); + +test('shouldRequestRiskInsight triggers attention near release with unfinished work even without history', () => { + assert.equal( + shouldRequestRiskInsight(risk({ riskLevel: 'attention', signals: { ...risk().signals, unfinishedCount: 2, daysToExpectedRelease: 1 } }), undefined), + true, + ); +}); + +test('shouldRequestRiskInsight triggers high risk levels', () => { + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'at_risk' }), undefined), true); + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'likely_delayed' }), undefined), true); + assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), undefined), true); +}); + +test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => { + const base = buildRiskInsightSignature(risk({ + signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 }, + currentSnapshot: snapshot({ openBugCount: 1, riskScore: 45 }), + })); + const changed = buildRiskInsightSignature(risk({ + signals: { ...risk().signals, openBugCount: 2, criticalBugCount: 0 }, + currentSnapshot: snapshot({ openBugCount: 2, riskScore: 45 }), + })); + + assert.notEqual(base, changed); +}); + +test('buildRiskInsightSignature changes when trend or evidence changes', () => { + const base = buildRiskInsightSignature(risk({ + trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' }, + })); + const changed = buildRiskInsightSignature(risk({ + trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' }, + dailyEvidence: { + ...risk().dailyEvidence!, + todayRisks: [{ id: 'ev-new', title: 'Risk', summary: 'New P1 regression appeared.', occurredAt: '2026-06-29T15:00:00.000Z' }], + }, + })); + + assert.notEqual(base, changed); +}); + +test('buildRiskInsightSignature changes when trend direction delta or activity metadata changes', () => { + const base = buildRiskInsightSignature(risk({ + trend: { direction: 'up', delta: 10, summary: 'Risk changed.', pattern: 'score_delta' }, + dailyEvidence: { + ...risk().dailyEvidence!, + recentActivityCount: 1, + lastActivityAt: '2026-06-29T10:00:00.000Z', + }, + })); + const changed = buildRiskInsightSignature(risk({ + trend: { direction: 'down', delta: -10, summary: 'Risk changed.', pattern: 'score_delta' }, + dailyEvidence: { + ...risk().dailyEvidence!, + recentActivityCount: 2, + lastActivityAt: '2026-06-29T11:00:00.000Z', + }, + })); + + assert.notEqual(base, changed); +}); + +test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => { + const payload = buildRiskInterpretRequest(risk()); + + assert.equal(payload.versionName, 'V1.0'); + assert.equal(payload.reasons[0].label, 'Failed tests'); + assert.equal(payload.reasons[0].severity, 'medium'); + assert.equal(payload.reasons[1].severity, 'critical'); + assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']); + assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']); + assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.'); +}); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts new file mode 100644 index 0000000..b5f1c6e --- /dev/null +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -0,0 +1,211 @@ +import type { AgentRiskInterpretError, AgentRiskInterpretRequest, AgentRiskInterpretResponse } from '@ftb/shared'; +import { api } from './api'; +import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk'; +import { findCachedInsight, type XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; +import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; + +type RiskInsightCurrent = Pick< + XiaobaoVersionRisk, + 'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend' +>; + +type RiskInsightPrevious = Pick< + XiaobaoRiskSnapshot, + | 'riskScore' + | 'confidence' + | 'forecastReleaseDate' + | 'openBugCount' + | 'criticalBugCount' + | 'failedTestCount' + | 'blockedCount' + | 'silentRiskCount' +>; + +const SCORE_TRIGGER_DELTA = 15; +const CONFIDENCE_DROP_DELTA = 15; +const ONE_DAY_MS = 86_400_000; + +export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean { + if (current.riskLevel === 'on_track') return false; + if (current.riskLevel === 'at_risk' || current.riskLevel === 'likely_delayed' || current.riskLevel === 'blocked') { + return true; + } + + const releaseIsNearWithUnfinishedWork = + current.signals.daysToExpectedRelease !== undefined && + current.signals.daysToExpectedRelease <= 1 && + current.signals.unfinishedCount > 0; + if (!previous) return isWorseningTrend(current) || releaseIsNearWithUnfinishedWork; + + const scoreDelta = current.riskScore - previous.riskScore; + const confidenceDrop = previous.confidence - current.confidence; + const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate + ? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime() + : 0; + + return ( + scoreDelta >= SCORE_TRIGGER_DELTA || + confidenceDrop >= CONFIDENCE_DROP_DELTA || + forecastDelayMs >= ONE_DAY_MS || + isWorseningTrend(current) || + current.signals.openBugCount > previous.openBugCount || + current.signals.criticalBugCount > (previous.criticalBugCount ?? 0) || + current.signals.failedTestCount > previous.failedTestCount || + current.signals.blockedCount > previous.blockedCount || + current.signals.silentRiskCount > previous.silentRiskCount || + releaseIsNearWithUnfinishedWork + ); +} + +export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { + return JSON.stringify({ + versionId: risk.versionId, + riskScore: clampScore(risk.riskScore), + riskLevel: risk.riskLevel, + expectedReleaseDate: risk.expectedReleaseDate ?? null, + forecastReleaseDate: risk.forecastReleaseDate ?? null, + delayDays: risk.delayDays, + confidence: clampScore(risk.confidence), + signals: { + unfinishedCount: risk.signals.unfinishedCount, + openBugCount: risk.signals.openBugCount, + criticalBugCount: risk.signals.criticalBugCount, + failedTestCount: risk.signals.failedTestCount, + blockedCount: risk.signals.blockedCount, + silentRiskCount: risk.signals.silentRiskCount, + daysToExpectedRelease: risk.signals.daysToExpectedRelease ?? null, + }, + trend: { + direction: risk.trend.direction, + delta: risk.trend.delta, + summary: risk.trend.summary, + pattern: risk.trend.pattern ?? null, + }, + reasons: normalizeReasons(risk.reasons), + silentRisks: normalizeSilentRisks(risk.silentRisks), + dailyEvidence: { + todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries), + todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress), + todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks), + progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes), + needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems), + recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, + lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null, + }, + }); +} + +export function getReusableInsight( + cache: XiaobaoRiskInsightCacheItem[], + risk: XiaobaoVersionRisk, +): XiaobaoRiskInsightCacheItem | undefined { + return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk)); +} + +export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest { + return { + versionId: risk.versionId, + versionName: risk.versionName, + productName: risk.productName, + projectName: risk.projectName, + riskScore: risk.riskScore, + riskLevel: risk.riskLevel, + expectedReleaseDate: risk.expectedReleaseDate, + forecastReleaseDate: risk.forecastReleaseDate, + delayDays: risk.delayDays, + confidence: risk.confidence, + signals: risk.signals, + trendSummary: risk.trend.summary, + reasons: risk.reasons.map(mapRiskReason), + silentRisks: risk.silentRisks.map((item) => ({ + key: item.key, + detail: item.detail, + })), + dailyEvidence: { + todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries), + todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress), + todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks), + progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes), + needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems), + recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, + lastActivityAt: risk.dailyEvidence?.lastActivityAt, + }, + }; +} + +export async function requestRiskInsight( + risk: XiaobaoVersionRisk, +): Promise { + return api.postRaw( + '/ai/risk-interpret', + buildRiskInterpretRequest(risk), + 120000, + ); +} + +function mapRiskReason(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number] { + return { + key: reason.key, + label: reason.title, + severity: mapReasonSeverity(reason), + detail: reason.detail, + }; +} + +function mapReasonSeverity(reason: RiskReason): AgentRiskInterpretRequest['reasons'][number]['severity'] { + if (reason.severity === 'info') return 'low'; + if (reason.severity === 'warning') return 'medium'; + if (reason.key === 'critical_bug' || reason.key === 'blocked_work') return 'critical'; + return 'high'; +} + +function summarizeEvidence(items: Array<{ summary: string }> | undefined): string[] { + return (items ?? []).map((item) => item.summary).filter((summary) => summary.trim().length > 0); +} + +function isWorseningTrend(current: RiskInsightCurrent): boolean { + return current.trend.direction === 'up' && current.trend.pattern === 'continuous_rising'; +} + +function normalizeReasons(reasons: XiaobaoVersionRisk['reasons']) { + return reasons + .map((reason) => ({ + key: reason.key, + title: reason.title, + severity: reason.severity, + detail: reason.detail, + count: reason.count ?? null, + })) + .sort(compareSignatureRows); +} + +function normalizeSilentRisks(silentRisks: XiaobaoVersionRisk['silentRisks']) { + return silentRisks + .map((item) => ({ + key: item.key, + title: item.title, + detail: item.detail, + itemId: item.itemId ?? null, + itemType: item.itemType ?? null, + })) + .sort(compareSignatureRows); +} + +function normalizeEvidence(items: Array<{ id?: string; title?: string; summary: string; occurredAt?: string }> | undefined) { + return (items ?? []) + .map((item) => ({ + id: item.id ?? null, + title: item.title ?? null, + summary: item.summary, + occurredAt: item.occurredAt ?? null, + })) + .sort(compareSignatureRows); +} + +function compareSignatureRows(a: T, b: T): number { + return JSON.stringify(a).localeCompare(JSON.stringify(b)); +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts index 2424c11..d1459ea 100644 --- a/apps/web/lib/xiaobao-risk-trend.test.ts +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -41,6 +41,13 @@ test('buildRiskSignature changes when score and bug counts change', () => { assert.notEqual(base, changed); }); +test('buildRiskSignature changes when critical bug count changes without open bug count change', () => { + const base = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 0 })); + const changed = buildRiskSignature(snapshot({ openBugCount: 2, criticalBugCount: 1 })); + + assert.notEqual(base, changed); +}); + 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 c3249e1..0e88ff0 100644 --- a/apps/web/lib/xiaobao-risk-trend.ts +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -7,6 +7,7 @@ export interface XiaobaoRiskSnapshot { riskLevel: XiaobaoRiskLevel; forecastReleaseDate?: string; openBugCount: number; + criticalBugCount?: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; @@ -76,6 +77,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { snapshot.riskLevel, snapshot.forecastReleaseDate ?? '', snapshot.openBugCount, + snapshot.criticalBugCount ?? 0, snapshot.failedTestCount, snapshot.blockedCount, snapshot.silentRiskCount, diff --git a/apps/web/lib/xiaobao-risk.test.ts b/apps/web/lib/xiaobao-risk.test.ts index 51f4e38..9a2372e 100644 --- a/apps/web/lib/xiaobao-risk.test.ts +++ b/apps/web/lib/xiaobao-risk.test.ts @@ -180,3 +180,17 @@ test('calcXiaobaoVersionRisk preserves null expected release date as a compatibl assert.equal(risk.expectedReleaseDate, null); }); + +test('calcXiaobaoVersionRisk preserves version display context for AI interpretation', () => { + const risk = calcXiaobaoVersionRisk({ + version: version({ name: 'V2.0', productName: 'FTB', projectName: 'Project PM' }), + devTasks: [], + testCases: [], + bugs: [], + now: new Date('2026-07-02T01:00:00.000Z'), + }); + + assert.equal(risk.versionName, 'V2.0'); + assert.equal(risk.productName, 'FTB'); + assert.equal(risk.projectName, 'Project PM'); +}); diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 8197766..64adc1f 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -49,6 +49,9 @@ export interface XiaobaoRiskSignals { export interface XiaobaoVersionRisk { versionId: string; + versionName: string; + productName?: string; + projectName?: string; riskScore: number; riskLevel: XiaobaoRiskLevel; expectedReleaseDate: string | null; @@ -67,6 +70,7 @@ export interface XiaobaoVersionRisk { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; summary: string; + pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; }; } @@ -208,6 +212,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao riskLevel, forecastReleaseDate, openBugCount: signals.openBugCount, + criticalBugCount: signals.criticalBugCount, failedTestCount: signals.failedTestCount, blockedCount: signals.blockedCount, silentRiskCount: signals.silentRiskCount, @@ -218,6 +223,9 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao return { versionId: input.version.id, + versionName: input.version.name, + productName: input.version.productName, + projectName: input.version.projectName, riskScore, riskLevel, expectedReleaseDate: input.version.expectedReleaseDate ?? null, From cf02ce9266678fef1582152abb239e7fb90722c8 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 19:51:37 +0800 Subject: [PATCH 07/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E5=A2=9E=E5=8A=A0=E9=A2=84=E8=AD=A6=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 222 ++++++++++++++++++ apps/web/components/layout/Sidebar.tsx | 3 +- .../xiaobao-warning/XiaobaoWarningCard.tsx | 95 ++++++++ .../xiaobao-warning/XiaobaoWarningDrawer.tsx | 153 ++++++++++++ apps/web/lib/xiaobao-warning-view.test.ts | 47 ++++ apps/web/lib/xiaobao-warning-view.ts | 20 ++ 6 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/xiaobao-warning/page.tsx create mode 100644 apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx create mode 100644 apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx create mode 100644 apps/web/lib/xiaobao-warning-view.test.ts create mode 100644 apps/web/lib/xiaobao-warning-view.ts diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx new file mode 100644 index 0000000..44f1e04 --- /dev/null +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react'; +import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; +import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard'; +import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useBugStore } from '@/stores/useBugStore'; +import { useDevTaskStore } from '@/stores/useDevTaskStore'; +import { useProductStore } from '@/stores/useProductStore'; +import { useRequirementStore } from '@/stores/useRequirementStore'; +import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; +import { useTestCaseStore } from '@/stores/useTestCaseStore'; +import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; +import { flattenVersions } from '@/lib/derive'; +import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; +import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; + +type RiskFilter = 'all' | 'attention' | 'high'; + +export default function XiaobaoWarningPage() { + return ( + + + + ); +} + +function XiaobaoWarningContent() { + const router = useRouter(); + const canManage = useHasPermission('xiaobao.warning:manage'); + const user = useAuthStore((s) => s.user); + const { overview, fetchOverview } = useProductStore(); + const { plans, fetchPlans } = useVersionPlanStore(); + const { requirements, fetchRequirements } = useRequirementStore(); + const { tasks: devTasks, fetchTasks } = useDevTaskStore(); + const { testCases, fetchTestCases } = useTestCaseStore(); + const { bugs, fetchBugs } = useBugStore(); + const { activities, fetchActivities } = useWorkActivityStore(); + const { worklogs, fetchWorklogs } = useTaskWorklogStore(); + const [selectedRiskId, setSelectedRiskId] = useState(null); + const [filter, setFilter] = useState('all'); + + useEffect(() => { fetchOverview(); }, [fetchOverview]); + useEffect(() => { fetchPlans(); }, [fetchPlans]); + useEffect(() => { fetchRequirements(); }, [fetchRequirements]); + useEffect(() => { fetchTasks(); }, [fetchTasks]); + useEffect(() => { fetchTestCases(); }, [fetchTestCases]); + useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { fetchActivities(); }, [fetchActivities]); + useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + + const allVersions = useMemo(() => flattenVersions(overview), [overview]); + const visibleVersions = useMemo( + () => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }), + [allVersions, canManage, user?.name], + ); + + const requirementVersionMap = useMemo(() => { + const map = new Map(); + requirements.forEach((requirement) => { + if (requirement.versionId) map.set(requirement.id, requirement.versionId); + }); + return map; + }, [requirements]); + + const workItems = useMemo(() => buildXiaobaoWorkItems({ + plans, + devTasks, + testCases, + bugs, + versions: allVersions.map((version) => ({ + id: version.id, + name: version.name, + productName: version.productName, + projectName: version.projectName, + })), + requirementVersionMap, + }), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]); + + const risks = useMemo(() => visibleVersions.map((version) => { + const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id); + const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id)); + const dailyEvidence = buildVersionDailyEvidence({ + versionId: version.id, + workItems, + activities, + worklogs, + }); + + return calcXiaobaoVersionRisk({ + version, + devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)), + testCases: testCases.filter((testCase) => testCase.versionId === version.id), + bugs: bugs.filter((bug) => bug.versionId === version.id), + dailyEvidence, + recentActivityCount: dailyEvidence.recentActivityCount, + lastActivityAt: dailyEvidence.lastActivityAt, + snapshots: [], + }); + }).sort((a, b) => b.riskScore - a.riskScore), [ + activities, + bugs, + devTasks, + requirements, + testCases, + visibleVersions, + workItems, + worklogs, + ]); + + const filteredRisks = useMemo(() => risks.filter((risk) => { + if (filter === 'attention') return risk.riskLevel !== 'on_track'; + if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel); + return true; + }), [filter, risks]); + + const selectedRisk = selectedRiskId ? risks.find((risk) => risk.versionId === selectedRiskId) ?? null : null; + const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length; + const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length; + const avgConfidence = risks.length > 0 + ? Math.round(risks.reduce((sum, risk) => sum + risk.confidence, 0) / risks.length) + : 0; + + return ( +
+
+
+
+ +
+
+

小宝预警

+

+ {canManage ? '管理视角:全部未结束版本' : '个人视角:我参与的未结束版本'} +

+
+
+
+ {canManage && ( + + + 管理权限 + + )} + {filteredRisks.length} / {risks.length} 个版本 +
+
+ +
+
+ + + + +
+ +
+
+ setFilter('all')}>全部 + setFilter('attention')}>需关注 + setFilter('high')}>高风险 +
+
+ + 截止日期按版本期望发版日期计算 +
+
+ + {filteredRisks.length === 0 ? ( +
+

暂无小宝预警

+

当前筛选范围内没有需要展示的未结束版本

+
+ ) : ( +
+ {filteredRisks.map((risk) => ( + setSelectedRiskId(risk.versionId)} /> + ))} +
+ )} +
+ + {selectedRisk && ( + setSelectedRiskId(null)} + onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)} + /> + )} +
+ ); +} + +function SummaryTile({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 9568337..1185be4 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePathname, useRouter } from 'next/navigation'; -import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; @@ -10,6 +10,7 @@ const NAV_GROUPS = [ { label: '工作区', items: [ + { label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view' }, { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null }, { label: '产品', path: '/products', icon: Package, permission: 'product:view' }, { label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' }, diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx new file mode 100644 index 0000000..5e1608b --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react'; +import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { formatDateTime } from '@/lib/format'; + +const RISK_LEVEL_LABEL: Record = { + on_track: '按期', + attention: '关注', + at_risk: '有风险', + likely_delayed: '大概率延期', + blocked: '阻塞', +}; + +const RISK_LEVEL_STYLE: Record = { + on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200', + attention: 'bg-amber-50 text-amber-700 border-amber-200', + at_risk: 'bg-orange-50 text-orange-700 border-orange-200', + likely_delayed: 'bg-red-50 text-red-700 border-red-200', + blocked: 'bg-zinc-900 text-white border-zinc-900', +}; + +export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) { + const firstReason = risk.reasons[0]; + const TrendIcon = risk.trend.direction === 'down' ? TrendingDown : risk.trend.direction === 'up' ? TrendingUp : TriangleAlert; + + return ( + + ); +} + +function Metric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) { + const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600'; + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx new file mode 100644 index 0000000..8258110 --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { CalendarClock, Sparkles, X } from 'lucide-react'; +import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { formatDateTime } from '@/lib/format'; + +export function XiaobaoWarningDrawer({ + risk, + onClose, + onNavigate, +}: { + risk: XiaobaoVersionRisk; + onClose: () => void; + onNavigate: () => void; +}) { + const evidence = risk.dailyEvidence; + const evidenceItems = [ + ...(evidence?.todayDeliveries ?? []), + ...(evidence?.todayProgress ?? []), + ...(evidence?.todayRisks ?? []), + ...(evidence?.progressNotes ?? []), + ]; + + return ( +
+
+
+ {risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName} +
+
+
+

小宝预警详情

+

规则预警会保留事实证据,AI 解读自动补充

+
+ +
+ +
+
+ + + + 0 ? `${risk.delayDays}天` : '0天'} /> +
+ +
+
+

+ + 期望发版: {formatDateTime(risk.expectedReleaseDate)} +

+

预测可发: {formatDateTime(risk.forecastReleaseDate)}

+

{risk.trend.summary}

+
+
+ +
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => ( +
+
+

{reason.title}

+ {reason.severity} +
+

{reason.detail}

+
+ ))} +
+ +
+ {evidenceItems.length === 0 ? : evidenceItems.map((item) => ( +
+

{item.title}

+

{item.summary}

+

{formatDateTime(item.occurredAt)}

+
+ ))} +
+ +
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => ( +
+

{item.title}

+

{item.detail}

+
+ ))} +
+ +
+ {risk.aiInsight ? ( +
+
+

+ + {risk.aiInsight.summary} +

+

{risk.aiInsight.forecast}

+
+ {risk.aiInsight.suggestedActions.map((action) => ( +
+ {action} +
+ ))} +
+ ) : ( + + )} +
+ + +
+
+
+ ); +} + +function Summary({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts new file mode 100644 index 0000000..c8c7939 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { VersionWithContext } from './derive'; +import { filterXiaobaoWarningVersions } from './xiaobao-warning-view'; + +function version(patch: Partial = {}): VersionWithContext { + return { + id: 'ver-1', + name: 'V1.0', + status: 'developing', + releaseDate: null, + createdAt: '2026-06-29T00:00:00.000Z', + productId: 'prod-1', + productName: 'FTB', + projectId: 'proj-1', + projectName: 'Project', + expectedReleaseDate: '2026-07-05T10:00:00.000Z', + members: [{ name: 'Alice', role: 'frontend' }], + ...patch, + }; +} + +test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => { + const result = filterXiaobaoWarningVersions( + [ + version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), + version({ id: 'ver-2', status: 'planned', members: [{ name: 'Bob', role: 'testing' }] }), + version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }), + ], + { canManage: true, userName: 'Alice' }, + ); + + assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']); +}); + +test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => { + const result = filterXiaobaoWarningVersions( + [ + version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), + version({ id: 'ver-2', status: 'developing', members: [{ name: 'Bob', role: 'testing' }] }), + version({ id: 'ver-3', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }), + ], + { canManage: false, userName: 'Alice' }, + ); + + assert.deepEqual(result.map((item) => item.id), ['ver-1']); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts new file mode 100644 index 0000000..d88ac94 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -0,0 +1,20 @@ +import type { VersionWithContext } from './derive'; + +const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']); + +export interface XiaobaoWarningVersionFilter { + canManage: boolean; + userName?: string; +} + +export function filterXiaobaoWarningVersions( + versions: VersionWithContext[], + filter: XiaobaoWarningVersionFilter, +): VersionWithContext[] { + return versions.filter((version) => { + if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false; + if (filter.canManage) return true; + if (!filter.userName) return false; + return (version.members ?? []).some((member) => member.name === filter.userName); + }); +} From f8db9aa384bed4847e5c1c7ec78e84d2df7e4c1f Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 19:56:11 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=20AI=20=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E8=A7=A3=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 62 ++++++++++++++++++++++++--- apps/web/lib/xiaobao-risk-ai.test.ts | 16 +++++++ apps/web/lib/xiaobao-risk-ai.ts | 10 +++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 44f1e04..3224624 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react'; import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; @@ -15,9 +15,12 @@ import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; 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 { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; +import { buildRiskSignature } from '@/lib/xiaobao-risk-trend'; import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; type RiskFilter = 'all' | 'attention' | 'high'; @@ -42,8 +45,13 @@ function XiaobaoWarningContent() { const { bugs, fetchBugs } = useBugStore(); const { activities, fetchActivities } = useWorkActivityStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); + const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); const [selectedRiskId, setSelectedRiskId] = useState(null); const [filter, setFilter] = useState('all'); + const [calculationNow] = useState(() => new Date()); + const savedSnapshotKeysRef = useRef(new Set()); + const requestedInsightKeysRef = useRef(new Set()); + const today = useMemo(() => new Date().toISOString().slice(0, 10), []); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchPlans(); }, [fetchPlans]); @@ -53,6 +61,7 @@ function XiaobaoWarningContent() { useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchActivities(); }, [fetchActivities]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + useEffect(() => { fetchRiskData(); }, [fetchRiskData]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); const visibleVersions = useMemo( @@ -100,7 +109,8 @@ function XiaobaoWarningContent() { dailyEvidence, recentActivityCount: dailyEvidence.recentActivityCount, lastActivityAt: dailyEvidence.lastActivityAt, - snapshots: [], + snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), + now: calculationNow, }); }).sort((a, b) => b.riskScore - a.riskScore), [ activities, @@ -111,15 +121,57 @@ function XiaobaoWarningContent() { visibleVersions, workItems, worklogs, + snapshots, + today, + calculationNow, ]); - const filteredRisks = useMemo(() => risks.filter((risk) => { + useEffect(() => { + risks.forEach((risk) => { + const snapshot = risk.currentSnapshot; + const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`; + if (savedSnapshotKeysRef.current.has(key)) return; + savedSnapshotKeysRef.current.add(key); + saveSnapshot(snapshot).catch(() => { + savedSnapshotKeysRef.current.delete(key); + }); + }); + }, [risks, saveSnapshot]); + + useEffect(() => { + risks.forEach((risk) => { + if (getReusableInsight(insights, risk)) return; + const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); + if (!shouldRequestRiskInsight(risk, previous)) return; + const signature = buildRiskInsightSignature(risk); + const key = `${risk.versionId}:${signature}`; + if (requestedInsightKeysRef.current.has(key)) return; + requestedInsightKeysRef.current.add(key); + requestRiskInsight(risk).then((response) => { + if (!response.ok) return; + saveInsight({ + versionId: risk.versionId, + riskSignature: signature, + insight: response.result, + generatedAt: new Date().toISOString(), + providerInfo: { model: response.meta.model }, + }).catch(() => {}); + }).catch(() => {}); + }); + }, [insights, risks, saveInsight, snapshots, today]); + + const risksWithInsight = useMemo(() => risks.map((risk) => { + const cached = getReusableInsight(insights, risk); + return cached ? { ...risk, aiInsight: cached.insight } : risk; + }), [insights, risks]); + + const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => { if (filter === 'attention') return risk.riskLevel !== 'on_track'; if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel); return true; - }), [filter, risks]); + }), [filter, risksWithInsight]); - const selectedRisk = selectedRiskId ? risks.find((risk) => risk.versionId === selectedRiskId) ?? null : null; + const selectedRisk = selectedRiskId ? risksWithInsight.find((risk) => risk.versionId === selectedRiskId) ?? null : null; const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length; const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length; const avgConfidence = risks.length > 0 diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 288fa56..7abe5ec 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -5,6 +5,7 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { buildRiskInsightSignature, buildRiskInterpretRequest, + findPreviousRiskSnapshot, shouldRequestRiskInsight, } from './xiaobao-risk-ai'; @@ -243,3 +244,18 @@ test('buildRiskInterpretRequest compresses frontend risk evidence for the backen assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']); assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.'); }); + +test('findPreviousRiskSnapshot returns latest snapshot before today for the version', () => { + const previous = findPreviousRiskSnapshot( + [ + snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 70, createdAt: '2026-06-29T10:00:00.000Z' }), + snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, createdAt: '2026-06-28T10:00:00.000Z' }), + snapshot({ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, createdAt: '2026-06-27T10:00:00.000Z' }), + snapshot({ versionId: 'ver-2', date: '2026-06-28', riskScore: 90, createdAt: '2026-06-28T11:00:00.000Z' }), + ], + 'ver-1', + '2026-06-29', + ); + + assert.equal(previous?.riskScore, 52); +}); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index b5f1c6e..e32d48f 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -102,6 +102,16 @@ export function getReusableInsight( return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk)); } +export function findPreviousRiskSnapshot( + snapshots: XiaobaoRiskSnapshot[], + versionId: string, + today: string, +): XiaobaoRiskSnapshot | undefined { + return snapshots + .filter((snapshot) => snapshot.versionId === versionId && snapshot.date < today) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; +} + export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest { return { versionId: risk.versionId, From 37695a81011e63232bcd9d9b48e9574c4e8bd672 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 20:05:55 +0800 Subject: [PATCH 09/17] =?UTF-8?q?docs(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E6=9B=B4=E6=96=B0=E6=9E=B6=E6=9E=84=E4=B8=8E=20Agent=20?= =?UTF-8?q?=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-spec.md | 21 +++++++++++++++++++-- docs/architecture.md | 15 +++++++++++++++ docs/decisions.md | 12 ++++++++++++ docs/roadmap.md | 10 ++++++---- docs/workflow.md | 13 +++++++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/agent-spec.md b/docs/agent-spec.md index 09ebb15..af14fe5 100644 --- a/docs/agent-spec.md +++ b/docs/agent-spec.md @@ -105,9 +105,26 @@ - 不自动分配 assignee;AI 只提供可选推荐,用户确认采纳后才写入 - 不做"上一版基准 diff" -### Agent 2:Risk Watch Agent(风险预警)— 待规划 +### Agent 2:Risk Watch Agent(小宝预警解读) -仅占位,正式规划见 roadmap.md V3.2。 +**目的**:解释小宝预警规则引擎输出的版本发版风险结果,生成项目经理可读的风险原因、延期预测、建议发版窗口和处理动作。 + +**输入**: +- 版本上下文:产品、项目、版本、期望发版日期。 +- 规则风险结果:`riskScore`、`riskLevel`、`forecastReleaseDate`、`delayDays`、`confidence`、风险信号。 +- 趋势和快照:风险分变化、连续上升/下降、关键 Bug、失败用例、阻塞和静默风险变化。 +- 日报与工作活动证据:今日交付、今日进展、今日风险、进展备注、需要补充进展的事项。 + +**触发**: +- `on_track` 不触发。 +- `at_risk`、`likely_delayed`、`blocked` 自动触发。 +- `attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、失败用例增加、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。 + +**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。 + +**权限**:读规则结果和压缩证据;写 `xiaobao-risk-insights` 缓存。不修改 Version、Requirement、DevTask、TestCase、Bug、Member。 + +**失败回退**:AI 不可用时保留规则预警,前端显示“规则预警已生成,AI 解读会在触发条件满足时自动补充”。AI 失败不影响快照保存和规则风险展示。 ### Agent 3:Schedule Suggest Agent(排期建议)— 待规划 diff --git a/docs/architecture.md b/docs/architecture.md index 9ab453f..7a456a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,3 +177,18 @@ The personal daily report is derived from two inputs: `workspace-daily-report.ts` remains a pure aggregation engine. It groups today's current-user activity into delivery, progress, creation, risk, and note sections, and also detects in-progress work that started before today but has no activity or progress note today. This is intentionally not a generic rules engine or event bus. The rule surface is explicit, typed, and local to the workspace/daily-report use case. + +## Xiaobao Warning Layer (2026-06-29) + +Xiaobao Warning is a version-level release-risk capability shown above `/workspace` in the main navigation. It answers whether a version can ship on the expected release date, why it may not, roughly how long it may slip, and which release window is safer. + +The rule surface stays in pure frontend engines: + +- `xiaobao-risk.ts`: risk score, level, forecast release date, confidence, and current snapshot. +- `xiaobao-risk-evidence.ts`: version work aggregation, daily report/activity evidence, and silent-risk detection. +- `xiaobao-risk-trend.ts`: daily snapshots, trend detection, and snapshot signatures. +- `xiaobao-risk-ai.ts`: AI trigger policy, cache signature, and backend request mapping. + +Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`. + +AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent. diff --git a/docs/decisions.md b/docs/decisions.md index a178cec..1de04d4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -404,3 +404,15 @@ - 没有明确角色匹配或成员匹配时,AI 不输出推荐字段,任务保持未分配,供成员后续领取或手动分配。 **理由**:负责人推荐能减少项目经理初次分配成本,但分配本身是团队执行承诺,必须由人确认。把推荐和写入分开,可以复用版本成员上下文,又避免模型幻觉姓名或越权自动派单。 + +## 36. 小宝预警规则优先,AI 只做解释 + +**问题**:如果直接让 AI 判断版本能否发版,模型可能忽略系统内的任务、Bug、测试、日报和权限事实,结论不可追溯;如果只按风险等级触发 AI,又会漏掉同等级内风险剧变,例如 P1 Bug 从 0 到 3、测试失败、发版日只剩 1 天。 + +**决策**: +- 小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势、静默风险和证据摘要。 +- `on_track` 不触发 AI;`at_risk`、`likely_delayed`、`blocked` 自动触发 AI;`attention` 只有在风险分、趋势、关键 Bug、失败用例、阻塞、静默风险、置信度或预测日期出现明显恶化时触发。 +- AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。 +- AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。 + +**理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 96afb61..fdf87bb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -97,13 +97,15 @@ NestJS + Prisma + PostgreSQL 已开始接入。第一阶段先用 `app_data` JSO - 不做"上一版基准 diff"(按 decisions.md #17 决议) - 单 Agent 单 Round,不做多 Agent 编排 -### V3.2 — Risk Watch Agent + Schedule Suggest Agent +### V3.2 — 小宝预警 / Risk Watch Agent -**Risk Watch Agent**:自动识别延期/阻塞集中/工时偏差大的任务,提前预警 +小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险、预计可发日期和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版已落地页面触发模式:打开 `/xiaobao-warning` 时保存当天快照,并在满足触发条件时自动生成 AI 解读。 -**Schedule Suggest Agent**:基于成员负载和历史耗时,建议下一阶段任务分配 +当前不做后台定时 Agent。后续如果需要主动通知,再在已有 `xiaobao-risk-snapshots` 和 `xiaobao-risk-insights` 基础上增加定时巡检与消息推送。 -**多 Agent 协作设计**:到 V3.2 才真正涉及,当前 agent-spec.md 仅占位 +**Schedule Suggest Agent**:基于成员负载和历史耗时,建议下一阶段任务分配,仍作为后续候选。 + +**多 Agent 协作设计**:等 Risk Watch 与 Schedule Suggest 都稳定后再设计编排策略。 ### V3.3 — 其他场景候选 diff --git a/docs/workflow.md b/docs/workflow.md index 09b0342..3a69e8d 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -226,6 +226,19 @@ Implementation convention: - Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`. - Page components should consume report output, not rebuild report rules. +## 小宝预警工作流 + +小宝预警位于主导航“工作区 / 小宝预警”,展示在“与我相关”上方。可见范围由角色权限控制: + +- `xiaobao.warning:manage`:查看所有未结束版本的预警。 +- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。 + +页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。 + +AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 + +静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。 + ## 日期选择与计划时间 - 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。 From bf4b32f17d30c466b778756188d337bbb8e26199 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 09:28:12 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E5=A2=9E=E5=8A=A0=E5=BF=AB=E7=85=A7=E8=8A=82=E6=B5=81?= =?UTF-8?q?=E5=92=8C=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 解读输入。 From 50e76c8068b11b83e585673f291da822595777a3 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 09:35:47 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E5=89=A9=E4=BD=99=E5=B7=A5=E4=BD=9C=E9=87=8F=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=A4=A9=E6=95=B0=E6=8D=A2=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaobao-warning/XiaobaoWarningCard.tsx | 3 ++- .../xiaobao-warning/XiaobaoWarningDrawer.tsx | 3 ++- apps/web/lib/xiaobao-warning-view.test.ts | 9 ++++++++- apps/web/lib/xiaobao-warning-view.ts | 12 ++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx index 5e1608b..0434581 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -3,6 +3,7 @@ import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react'; import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { formatDateTime } from '@/lib/format'; +import { formatRemainingWork } from '@/lib/xiaobao-warning-view'; const RISK_LEVEL_LABEL: Record = { on_track: '按期', @@ -50,7 +51,7 @@ export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk
= 70 ? 'danger' : risk.riskScore >= 45 ? 'warn' : 'ok'} /> 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} /> - 0 ? 'warn' : 'ok'} /> + 0 ? 'warn' : 'ok'} />
diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx index 8258110..4fdebd9 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -3,6 +3,7 @@ import { CalendarClock, Sparkles, X } from 'lucide-react'; import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { formatDateTime } from '@/lib/format'; +import { formatRemainingWork } from '@/lib/xiaobao-warning-view'; export function XiaobaoWarningDrawer({ risk, @@ -46,7 +47,7 @@ export function XiaobaoWarningDrawer({
- + 0 ? `${risk.delayDays}天` : '0天'} />
diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts index c8c7939..fa0ed37 100644 --- a/apps/web/lib/xiaobao-warning-view.test.ts +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { VersionWithContext } from './derive'; -import { filterXiaobaoWarningVersions } from './xiaobao-warning-view'; +import { filterXiaobaoWarningVersions, formatRemainingWork } from './xiaobao-warning-view'; function version(patch: Partial = {}): VersionWithContext { return { @@ -45,3 +45,10 @@ test('filterXiaobaoWarningVersions limits non-managers to versions where they ar assert.deepEqual(result.map((item) => item.id), ['ver-1']); }); + +test('formatRemainingWork keeps hours and adds work-day conversion', () => { + assert.equal(formatRemainingWork(0), '0h / 0天'); + assert.equal(formatRemainingWork(8), '8h / 1天'); + assert.equal(formatRemainingWork(12), '12h / 1.5天'); + assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天'); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts index d88ac94..230e928 100644 --- a/apps/web/lib/xiaobao-warning-view.ts +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -1,4 +1,5 @@ import type { VersionWithContext } from './derive'; +import { WORK_HOURS } from './work-hours'; const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']); @@ -18,3 +19,14 @@ export function filterXiaobaoWarningVersions( return (version.members ?? []).some((member) => member.name === filter.userName); }); } + +export function formatRemainingWork(hours: number): string { + const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0; + const days = safeHours / WORK_HOURS.hoursPerDay; + return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`; +} + +function formatNumber(value: number): string { + if (Number.isInteger(value)) return String(value); + return value.toFixed(1).replace(/\.0$/, ''); +} From 4cecf2d77a31822570c38cfc8a426521fd7c68fd Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 09:44:41 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E8=BF=87=E6=BB=A4=E6=97=A0=E6=95=88=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/modules/ai/ai.service.spec.ts | 39 ++++++++++++++++++- apps/server/src/modules/ai/ai.service.ts | 19 ++++++++- .../src/modules/ai/prompts/risk-interpret.ts | 2 + apps/web/app/xiaobao-warning/page.tsx | 6 +-- apps/web/lib/xiaobao-warning-view.test.ts | 18 ++++++++- apps/web/lib/xiaobao-warning-view.ts | 20 ++++++++++ 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 957ff29..670479b 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -1,7 +1,7 @@ import { AiService } from './ai.service'; import type { AiGatewayService } from './ai-gateway.service'; import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; -import { RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret'; +import { RISK_INTERPRET_SYSTEM_PROMPT, RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { RiskInterpretDto } from './dto/risk-interpret.dto'; @@ -104,6 +104,38 @@ describe('AiService', () => { expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升'); }); + it('removes invalid page refresh suggestions from risk interpretation results', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: { + summary: '预计延期', + why: ['P1 Bug 增加'], + forecast: '预计 2026-07-04 可发', + suggestedActions: [ + '手动刷新小宝预警页面,等待风险自动更新', + '优先修复 P1 Bug 并安排测试复测', + ], + ownerHints: ['测试负责人同步失败用例'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }); + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.suggestedActions).toEqual(['优先修复 P1 Bug 并安排测试复测']); + } + }); + it('returns PARSE_ERROR when risk provider returns malformed payload', async () => { const gateway = { getActiveProvider: jest.fn().mockResolvedValue({ @@ -211,6 +243,11 @@ describe('AiService', () => { expect((RISK_INTERPRET_TOOL_INPUT_SCHEMA as any).additionalProperties).toBe(false); }); + it('tells the risk model not to suggest page refresh or manual update triggers', () => { + expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('不要建议刷新页面'); + expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('系统会自动更新'); + }); + it('returns PARSE_ERROR when provider reports invalid tool arguments', async () => { const gateway = { getActiveProvider: jest.fn().mockResolvedValue({ diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index c3684ba..535ca87 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -29,6 +29,13 @@ const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; const PROTOTYPE_FETCH_TIMEOUT_MS = 15000; const PROTOTYPE_FETCH_ATTEMPTS = 3; const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500; +const PAGE_REFRESH_ADVICE_PATTERNS = [ + /(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i, + /(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i, + /(手动|主动).*(触发|刷新).*(更新|预警|分析)/i, + /(manual|manually).*(refresh|reload|trigger)/i, + /(refresh|reload).*(page|browser|xiaobao|warning)/i, +]; @Injectable() export class AiService { @@ -222,8 +229,8 @@ export class AiService { summary, why, forecast, - suggestedActions, - ownerHints, + suggestedActions: this.filterPageRefreshAdvice(suggestedActions), + ownerHints: this.filterPageRefreshAdvice(ownerHints), generatedAt, }; if (input.recommendedReleaseWindow !== undefined) { @@ -234,6 +241,14 @@ export class AiService { return insight; } + private filterPageRefreshAdvice(items: string[]): string[] { + return items.filter((item) => !this.isPageRefreshAdvice(item)); + } + + private isPageRefreshAdvice(text: string): boolean { + return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text)); + } + private readString(value: unknown): string | null { return typeof value === 'string' ? value : null; } diff --git a/apps/server/src/modules/ai/prompts/risk-interpret.ts b/apps/server/src/modules/ai/prompts/risk-interpret.ts index 7f6216e..b5a8c67 100644 --- a/apps/server/src/modules/ai/prompts/risk-interpret.ts +++ b/apps/server/src/modules/ai/prompts/risk-interpret.ts @@ -13,6 +13,8 @@ export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的 输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。 如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。 +系统会自动更新小宝预警结果,用户不需要手动触发页面刷新或重新打开页面。 +suggestedActions 只能给业务动作,例如修复 Bug、补充日报、复测失败用例、解除阻塞、调整排期或确认负责人;不要建议刷新页面、手动触发更新、重新加载浏览器或等待页面自动更新。 输出必须通过 submit_risk_interpretation 工具返回,不要输出自然语言正文或 Markdown。`; diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 31c2feb..f779eb9 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -21,7 +21,7 @@ import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-r import { buildRiskInsightSignature, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; -import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; +import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; type RiskFilter = 'all' | 'attention' | 'high'; @@ -155,7 +155,7 @@ function XiaobaoWarningContent() { saveInsight({ versionId: risk.versionId, riskSignature: signature, - insight: response.result, + insight: sanitizeRiskInsight(response.result), generatedAt: new Date().toISOString(), providerInfo: { model: response.meta.model }, }).catch(() => {}); @@ -165,7 +165,7 @@ function XiaobaoWarningContent() { const risksWithInsight = useMemo(() => risks.map((risk) => { const cached = getReusableInsight(insights, risk); - return cached ? { ...risk, aiInsight: cached.insight } : risk; + return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk; }), [insights, risks]); const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => { diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts index fa0ed37..2b2c2f0 100644 --- a/apps/web/lib/xiaobao-warning-view.test.ts +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { VersionWithContext } from './derive'; -import { filterXiaobaoWarningVersions, formatRemainingWork } from './xiaobao-warning-view'; +import { filterXiaobaoWarningVersions, formatRemainingWork, sanitizeRiskInsight } from './xiaobao-warning-view'; function version(patch: Partial = {}): VersionWithContext { return { @@ -52,3 +52,19 @@ test('formatRemainingWork keeps hours and adds work-day conversion', () => { assert.equal(formatRemainingWork(12), '12h / 1.5天'); assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天'); }); + +test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => { + const result = sanitizeRiskInsight({ + summary: '风险上升', + why: ['P1 Bug 增加'], + forecast: '预计延期 1 天', + suggestedActions: [ + '手动触发页面刷新,等待小宝预警自动更新', + '优先处理 3 个 P1 Bug,并同步测试负责人复测', + ], + ownerHints: ['研发负责人协调修复顺序'], + generatedAt: '2026-06-30T10:00:00.000Z', + }); + + assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts index 230e928..457e991 100644 --- a/apps/web/lib/xiaobao-warning-view.ts +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -1,7 +1,15 @@ import type { VersionWithContext } from './derive'; +import type { XiaobaoRiskInsight } from './xiaobao-risk-cache'; import { WORK_HOURS } from './work-hours'; const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']); +const PAGE_REFRESH_ADVICE_PATTERNS = [ + /(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i, + /(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i, + /(手动|主动).*(触发|刷新).*(更新|预警|分析)/i, + /(manual|manually).*(refresh|reload|trigger)/i, + /(refresh|reload).*(page|browser|xiaobao|warning)/i, +]; export interface XiaobaoWarningVersionFilter { canManage: boolean; @@ -26,7 +34,19 @@ export function formatRemainingWork(hours: number): string { return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`; } +export function sanitizeRiskInsight(insight: XiaobaoRiskInsight): XiaobaoRiskInsight { + return { + ...insight, + suggestedActions: insight.suggestedActions.filter((action) => !isPageRefreshAdvice(action)), + ownerHints: insight.ownerHints.filter((hint) => !isPageRefreshAdvice(hint)), + }; +} + function formatNumber(value: number): string { if (Number.isInteger(value)) return String(value); return value.toFixed(1).replace(/\.0$/, ''); } + +function isPageRefreshAdvice(text: string): boolean { + return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text)); +} From eb9996a98797e1fc4520cd205e8c8ce65e51c0b3 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 09:56:45 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E7=AD=89=E5=BE=85=E7=BC=93=E5=AD=98=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E8=A7=A6=E5=8F=91AI=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 10 +++---- apps/web/lib/xiaobao-risk-ai.test.ts | 36 ++++++++++++++++++++++++++ apps/web/lib/xiaobao-risk-ai.ts | 13 ++++++++++ apps/web/stores/useXiaobaoRiskStore.ts | 4 +++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index f779eb9..d6f4f64 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -18,7 +18,7 @@ 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, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai'; +import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; @@ -45,7 +45,7 @@ function XiaobaoWarningContent() { const { bugs, fetchBugs } = useBugStore(); const { activities, fetchActivities } = useWorkActivityStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); - const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); + const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); const [selectedRiskId, setSelectedRiskId] = useState(null); const [filter, setFilter] = useState('all'); const [calculationNow] = useState(() => new Date()); @@ -142,10 +142,8 @@ function XiaobaoWarningContent() { useEffect(() => { risks.forEach((risk) => { - if (getReusableInsight(insights, risk)) return; const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); - const latestInsight = findLatestRiskInsightForVersion(insights, risk.versionId); - if (!shouldRequestRiskInsightWithCooldown(risk, previous, latestInsight)) return; + if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return; const signature = buildRiskInsightSignature(risk); const key = `${risk.versionId}:${signature}`; if (requestedInsightKeysRef.current.has(key)) return; @@ -161,7 +159,7 @@ function XiaobaoWarningContent() { }).catch(() => {}); }).catch(() => {}); }); - }, [insights, risks, saveInsight, snapshots, today]); + }, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]); const risksWithInsight = useMemo(() => risks.map((risk) => { const cached = getReusableInsight(insights, risk); diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index c9d7262..7fd18c9 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -8,6 +8,7 @@ import { findLatestRiskInsightForVersion, findPreviousRiskSnapshot, shouldRequestRiskInsight, + shouldRequestRiskInsightWithCacheGate, shouldRequestRiskInsightWithCooldown, } from './xiaobao-risk-ai'; import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; @@ -243,6 +244,41 @@ test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level esc assert.equal(shouldRequestRiskInsightWithCooldown(current, snapshot({ riskScore: 50 }), previousInsight, new Date('2026-06-29T11:00:00.000Z')), true); }); +test('shouldRequestRiskInsightWithCacheGate waits for cache loading before AI requests', () => { + const current = risk({ riskLevel: 'at_risk', riskScore: 82 }); + + assert.equal( + shouldRequestRiskInsightWithCacheGate(false, [], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')), + false, + ); +}); + +test('shouldRequestRiskInsightWithCacheGate reuses unchanged cached insight after cache loading', () => { + const current = risk({ riskLevel: 'at_risk', riskScore: 82 }); + const cached = insight({ + riskSignature: buildRiskInsightSignature(current), + generatedAt: '2026-06-29T02:00:00.000Z', + }); + + assert.equal( + shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')), + false, + ); +}); + +test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cache loading and cooldown', () => { + const current = risk({ riskLevel: 'at_risk', riskScore: 82 }); + const previousInsight = insight({ + riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 62 })), + generatedAt: '2026-06-29T02:00:00.000Z', + }); + + assert.equal( + shouldRequestRiskInsightWithCacheGate(true, [previousInsight], current, snapshot({ riskScore: 45 }), 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 ae5a8ed..7219dfe 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -90,6 +90,19 @@ export function shouldRequestRiskInsightWithCooldown( return nowTime - generatedAt >= RISK_INSIGHT_COOLDOWN_MS; } +export function shouldRequestRiskInsightWithCacheGate( + riskCacheLoaded: boolean, + cache: XiaobaoRiskInsightCacheItem[], + current: XiaobaoVersionRisk, + previous?: RiskInsightPrevious, + now: Date = new Date(), +): boolean { + if (!riskCacheLoaded) return false; + if (getReusableInsight(cache, current)) return false; + const latestInsight = findLatestRiskInsightForVersion(cache, current.versionId); + return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now); +} + export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { return JSON.stringify({ versionId: risk.versionId, diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 960350c..48d3d54 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -13,6 +13,7 @@ import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend'; interface XiaobaoRiskState { snapshots: XiaobaoRiskSnapshot[]; insights: XiaobaoRiskInsightCacheItem[]; + riskDataLoaded: boolean; error?: string; fetchRiskData: () => Promise; saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; @@ -41,13 +42,16 @@ let insightSaveQueue: Promise = Promise.resolve(); export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], insights: [], + riskDataLoaded: false, error: undefined, fetchRiskData: async () => { + set({ riskDataLoaded: false }); const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]); set({ ...(snapshots ? { snapshots } : {}), ...(insights ? { insights } : {}), + riskDataLoaded: snapshots !== null && insights !== null, error: snapshots === null || insights === null ? '小宝预警缓存加载失败' : undefined, }); }, From 0ca13d406c3a706894434bd19cc66c62caf5ad92 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 10:03:12 +0800 Subject: [PATCH 14/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E7=A8=B3=E5=AE=9AAI=E5=BB=BA=E8=AE=AE=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-ai.test.ts | 33 +++++++++++++++++++ apps/web/lib/xiaobao-risk-ai.ts | 49 +++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 7fd18c9..7058054 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -7,6 +7,7 @@ import { buildRiskInterpretRequest, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, + getReusableInsight, shouldRequestRiskInsight, shouldRequestRiskInsightWithCacheGate, shouldRequestRiskInsightWithCooldown, @@ -328,6 +329,38 @@ test('buildRiskInsightSignature changes when trend direction delta or activity m assert.notEqual(base, changed); }); +test('buildRiskInsightSignature stays stable when only volatile same-day forecast timing changes', () => { + const base = buildRiskInsightSignature(risk({ + forecastReleaseDate: '2026-07-14T03:06:58.211Z', + signals: { ...risk().signals, daysToExpectedRelease: 30.9 }, + })); + const changed = buildRiskInsightSignature(risk({ + forecastReleaseDate: '2026-07-14T03:37:15.903Z', + signals: { ...risk().signals, daysToExpectedRelease: 30.1 }, + })); + + assert.equal(base, changed); +}); + +test('getReusableInsight reuses legacy signatures with volatile forecast timestamps', () => { + const current = risk({ + forecastReleaseDate: '2026-07-14T03:37:15.903Z', + signals: { ...risk().signals, daysToExpectedRelease: 30.1 }, + }); + const legacy = insight({ + riskSignature: JSON.stringify({ + ...JSON.parse(buildRiskInsightSignature(current)), + forecastReleaseDate: '2026-07-14T03:06:58.211Z', + signals: { + ...current.signals, + daysToExpectedRelease: 30.9, + }, + }), + }); + + assert.equal(getReusableInsight([legacy], current)?.insight.summary, legacy.insight.summary); +}); + test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => { const payload = buildRiskInterpretRequest(risk()); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index 7219dfe..b9123f8 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -108,8 +108,8 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { versionId: risk.versionId, riskScore: clampScore(risk.riskScore), riskLevel: risk.riskLevel, - expectedReleaseDate: risk.expectedReleaseDate ?? null, - forecastReleaseDate: risk.forecastReleaseDate ?? null, + expectedReleaseDate: normalizeDateKey(risk.expectedReleaseDate), + forecastReleaseDate: normalizeDateKey(risk.forecastReleaseDate), delayDays: risk.delayDays, confidence: clampScore(risk.confidence), signals: { @@ -119,7 +119,7 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { failedTestCount: risk.signals.failedTestCount, blockedCount: risk.signals.blockedCount, silentRiskCount: risk.signals.silentRiskCount, - daysToExpectedRelease: risk.signals.daysToExpectedRelease ?? null, + daysToExpectedRelease: normalizeDaysToExpectedRelease(risk.signals.daysToExpectedRelease), }, trend: { direction: risk.trend.direction, @@ -145,7 +145,11 @@ export function getReusableInsight( cache: XiaobaoRiskInsightCacheItem[], risk: XiaobaoVersionRisk, ): XiaobaoRiskInsightCacheItem | undefined { - return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk)); + const currentSignature = buildRiskInsightSignature(risk); + return ( + findCachedInsight(cache, risk.versionId, currentSignature) ?? + cache.find((item) => item.versionId === risk.versionId && normalizeCachedRiskSignature(item.riskSignature) === currentSignature) + ); } export function findPreviousRiskSnapshot( @@ -284,6 +288,43 @@ function parseRiskLevel(signature: string): XiaobaoVersionRisk['riskLevel'] | un } } +function normalizeCachedRiskSignature(signature: string): string | undefined { + try { + const parsed = JSON.parse(signature) as { + expectedReleaseDate?: string | null; + forecastReleaseDate?: string | null; + signals?: { daysToExpectedRelease?: number | null }; + }; + return JSON.stringify({ + ...parsed, + expectedReleaseDate: normalizeDateKey(parsed.expectedReleaseDate), + forecastReleaseDate: normalizeDateKey(parsed.forecastReleaseDate), + signals: { + ...parsed.signals, + daysToExpectedRelease: normalizeDaysToExpectedRelease(parsed.signals?.daysToExpectedRelease), + }, + }); + } catch { + return undefined; + } +} + +function normalizeDateKey(value: string | null | undefined): string | null { + if (!value) return null; + const raw = value.trim(); + if (raw.length === 0) return null; + if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10); + + const time = new Date(raw).getTime(); + if (!Number.isFinite(time)) return raw; + return new Date(time).toISOString().slice(0, 10); +} + +function normalizeDaysToExpectedRelease(value: number | null | undefined): number | null { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value >= 0 ? Math.ceil(value) : Math.floor(value); +} + function getTime(value: string): number { const time = new Date(value).getTime(); return Number.isFinite(time) ? time : Number.NaN; From 85ec416bc2e586ef060ab55609c4a65a40b8d8cf Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 10:23:01 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?):=20=E8=B0=83=E6=95=B4=E9=A3=8E=E9=99=A9=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=92=8C=E5=AF=BC=E8=88=AA=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 485 ++++++++++++------ apps/web/components/layout/Sidebar.tsx | 31 +- .../xiaobao-warning/XiaobaoWarningCard.tsx | 69 +-- apps/web/hooks/useXiaobaoWarningRisks.ts | 119 +++++ apps/web/lib/xiaobao-risk.ts | 6 + apps/web/lib/xiaobao-warning-view.test.ts | 84 ++- apps/web/lib/xiaobao-warning-view.ts | 28 + 7 files changed, 617 insertions(+), 205 deletions(-) create mode 100644 apps/web/hooks/useXiaobaoWarningRisks.ts diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index d6f4f64..bee87f3 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -2,28 +2,35 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react'; +import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react'; import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard'; -import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer'; -import { useAuthStore } from '@/stores/useAuthStore'; -import { useBugStore } from '@/stores/useBugStore'; -import { useDevTaskStore } from '@/stores/useDevTaskStore'; -import { useProductStore } from '@/stores/useProductStore'; -import { useRequirementStore } from '@/stores/useRequirementStore'; -import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; -import { useTestCaseStore } from '@/stores/useTestCaseStore'; -import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; -import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; -import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; -import { flattenVersions } from '@/lib/derive'; -import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; +import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; -import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; -import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; +import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight, type XiaobaoWarningRiskFilter } from '@/lib/xiaobao-warning-view'; +import { formatDateTime } from '@/lib/format'; -type RiskFilter = 'all' | 'attention' | 'high'; +const HIGH_RISK_LEVELS = new Set(['at_risk', 'likely_delayed', 'blocked']); + +const RISK_LEVEL_LABEL: Record = { + on_track: '按期', + attention: '关注', + at_risk: '有风险', + likely_delayed: '大概率延期', + blocked: '阻塞', +}; + +const RISK_LEVEL_STYLE: Record = { + on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700', + attention: 'border-amber-200 bg-amber-50 text-amber-700', + at_risk: 'border-orange-200 bg-orange-50 text-orange-700', + likely_delayed: 'border-red-200 bg-red-50 text-red-700', + blocked: 'border-zinc-900 bg-zinc-900 text-white', +}; + +type FilterOption = { id: string; label: string }; export default function XiaobaoWarningPage() { return ( @@ -36,95 +43,13 @@ export default function XiaobaoWarningPage() { function XiaobaoWarningContent() { const router = useRouter(); const canManage = useHasPermission('xiaobao.warning:manage'); - const user = useAuthStore((s) => s.user); - const { overview, fetchOverview } = useProductStore(); - const { plans, fetchPlans } = useVersionPlanStore(); - const { requirements, fetchRequirements } = useRequirementStore(); - const { tasks: devTasks, fetchTasks } = useDevTaskStore(); - const { testCases, fetchTestCases } = useTestCaseStore(); - const { bugs, fetchBugs } = useBugStore(); - const { activities, fetchActivities } = useWorkActivityStore(); - const { worklogs, fetchWorklogs } = useTaskWorklogStore(); - const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); + const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true }); const [selectedRiskId, setSelectedRiskId] = useState(null); - const [filter, setFilter] = useState('all'); - const [calculationNow] = useState(() => new Date()); + const [riskFilter, setRiskFilter] = useState('all'); + const [selectedProductId, setSelectedProductId] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(''); const savedSnapshotKeysRef = useRef(new Set()); const requestedInsightKeysRef = useRef(new Set()); - const today = useMemo(() => new Date().toISOString().slice(0, 10), []); - - useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchTasks(); }, [fetchTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); - useEffect(() => { fetchActivities(); }, [fetchActivities]); - useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); - useEffect(() => { fetchRiskData(); }, [fetchRiskData]); - - const allVersions = useMemo(() => flattenVersions(overview), [overview]); - const visibleVersions = useMemo( - () => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }), - [allVersions, canManage, user?.name], - ); - - const requirementVersionMap = useMemo(() => { - const map = new Map(); - requirements.forEach((requirement) => { - if (requirement.versionId) map.set(requirement.id, requirement.versionId); - }); - return map; - }, [requirements]); - - const workItems = useMemo(() => buildXiaobaoWorkItems({ - plans, - devTasks, - testCases, - bugs, - versions: allVersions.map((version) => ({ - id: version.id, - name: version.name, - productName: version.productName, - projectName: version.projectName, - })), - requirementVersionMap, - }), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]); - - const risks = useMemo(() => visibleVersions.map((version) => { - const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id); - const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id)); - const dailyEvidence = buildVersionDailyEvidence({ - versionId: version.id, - workItems, - activities, - worklogs, - }); - - return calcXiaobaoVersionRisk({ - version, - devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)), - testCases: testCases.filter((testCase) => testCase.versionId === version.id), - bugs: bugs.filter((bug) => bug.versionId === version.id), - dailyEvidence, - recentActivityCount: dailyEvidence.recentActivityCount, - lastActivityAt: dailyEvidence.lastActivityAt, - snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), - now: calculationNow, - }); - }).sort((a, b) => b.riskScore - a.riskScore), [ - activities, - bugs, - devTasks, - requirements, - testCases, - visibleVersions, - workItems, - worklogs, - snapshots, - today, - calculationNow, - ]); useEffect(() => { risks.forEach((risk) => { @@ -166,17 +91,47 @@ function XiaobaoWarningContent() { return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk; }), [insights, risks]); - const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => { - if (filter === 'attention') return risk.riskLevel !== 'on_track'; - if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel); - return true; - }), [filter, risksWithInsight]); + const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]); + const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]); + const projectOptions = useMemo( + () => buildProjectOptions(warningRisks, selectedProductId), + [selectedProductId, warningRisks], + ); - const selectedRisk = selectedRiskId ? risksWithInsight.find((risk) => risk.versionId === selectedRiskId) ?? null : null; - const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length; - const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length; - const avgConfidence = risks.length > 0 - ? Math.round(risks.reduce((sum, risk) => sum + risk.confidence, 0) / risks.length) + useEffect(() => { + if (selectedProductId && !productOptions.some((option) => option.id === selectedProductId)) { + setSelectedProductId(''); + setSelectedProjectId(''); + } + }, [productOptions, selectedProductId]); + + useEffect(() => { + if (selectedProjectId && !projectOptions.some((option) => option.id === selectedProjectId)) { + setSelectedProjectId(''); + } + }, [projectOptions, selectedProjectId]); + + const filteredRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight, { + riskFilter, + productId: selectedProductId || undefined, + projectId: selectedProjectId || undefined, + }), [riskFilter, risksWithInsight, selectedProductId, selectedProjectId]); + + useEffect(() => { + if (filteredRisks.length === 0) { + setSelectedRiskId(null); + return; + } + if (!selectedRiskId || !filteredRisks.some((risk) => risk.versionId === selectedRiskId)) { + setSelectedRiskId(filteredRisks[0].versionId); + } + }, [filteredRisks, selectedRiskId]); + + const selectedRisk = selectedRiskId + ? filteredRisks.find((risk) => risk.versionId === selectedRiskId) ?? null + : null; + const avgConfidence = filteredRisks.length > 0 + ? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length) : 0; return ( @@ -193,71 +148,248 @@ function XiaobaoWarningContent() {

-
+
{canManage && ( 管理权限 )} - {filteredRisks.length} / {risks.length} 个版本 +
+

平均置信

+

{avgConfidence}%

+
-
-
- - - - +
+ + +
+ {selectedRisk ? ( + router.push(`/versions/${selectedRisk.versionId}`)} + /> + ) : ( +
+ 选择左侧风险版本查看详情 +
+ )}
- -
-
- setFilter('all')}>全部 - setFilter('attention')}>需关注 - setFilter('high')}>高风险 -
-
- - 截止日期按版本期望发版日期计算 -
-
- - {filteredRisks.length === 0 ? ( -
-

暂无小宝预警

-

当前筛选范围内没有需要展示的未结束版本

-
- ) : ( -
- {filteredRisks.map((risk) => ( - setSelectedRiskId(risk.versionId)} /> - ))} -
- )}
- - {selectedRisk && ( - setSelectedRiskId(null)} - onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)} - /> - )}
); } -function SummaryTile({ label, value }: { label: string; value: string }) { +function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionRisk; onNavigate: () => void }) { + const evidence = risk.dailyEvidence; + const evidenceItems = [ + ...(evidence?.todayDeliveries ?? []), + ...(evidence?.todayProgress ?? []), + ...(evidence?.todayRisks ?? []), + ...(evidence?.progressNotes ?? []), + ]; + + return ( +
+
+
+

{risk.productName ?? '-'} / {risk.projectName ?? '-'}

+
+

{risk.versionName}

+ +
+
+ +
+ +
+ = 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} /> + + 0 ? 'warn' : 'ok'} /> + 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} /> +
+ +
+ {risk.aiInsight ? ( +
+
+

+ + {risk.aiInsight.summary} +

+

{risk.aiInsight.forecast}

+ {risk.aiInsight.recommendedReleaseWindow && ( +

{risk.aiInsight.recommendedReleaseWindow}

+ )} +
+
+ {risk.aiInsight.suggestedActions.map((action) => ( +
+ {action} +
+ ))} +
+
+ ) : ( + + )} +
+ +
+
+
+

+ + 期望发版:{formatDateTime(risk.expectedReleaseDate)} +

+

预测可发:{formatDateTime(risk.forecastReleaseDate)}

+

{risk.trend.summary}

+
+
+ +
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => ( +
+
+

{reason.title}

+ {reason.severity} +
+

{reason.detail}

+
+ ))} +
+
+ +
+
+ {evidenceItems.length === 0 ? : evidenceItems.map((item) => ( +
+

{item.title}

+

{item.summary}

+

{formatDateTime(item.occurredAt)}

+
+ ))} +
+ +
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => ( +
+

{item.title}

+

{item.detail}

+
+ ))} +
+
+
+ ); +} + +function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) { + return ( + + {RISK_LEVEL_LABEL[level]} + + ); +} + +function DetailMetric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) { + const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600'; return (

{label}

-

{value}

+

{value}

); } +function SelectFilter({ + label, + value, + allLabel, + options, + onChange, +}: { + label: string; + value: string; + allLabel: string; + options: FilterOption[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return ( ); } + +function XiaobaoRiskNavBadge() { + const { risks } = useXiaobaoWarningRisks(); + const count = getXiaobaoWarningRiskCount(risks); + if (count <= 0) return null; + + return ( + + {count > 99 ? '99+' : count} + + ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx index 0434581..6828c09 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -1,9 +1,7 @@ 'use client'; -import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react'; +import { ChevronRight, TriangleAlert } from 'lucide-react'; import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; -import { formatDateTime } from '@/lib/format'; -import { formatRemainingWork } from '@/lib/xiaobao-warning-view'; const RISK_LEVEL_LABEL: Record = { on_track: '按期', @@ -14,31 +12,40 @@ const RISK_LEVEL_LABEL: Record = { }; const RISK_LEVEL_STYLE: Record = { - on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200', - attention: 'bg-amber-50 text-amber-700 border-amber-200', - at_risk: 'bg-orange-50 text-orange-700 border-orange-200', - likely_delayed: 'bg-red-50 text-red-700 border-red-200', - blocked: 'bg-zinc-900 text-white border-zinc-900', + on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700', + attention: 'border-amber-200 bg-amber-50 text-amber-700', + at_risk: 'border-orange-200 bg-orange-50 text-orange-700', + likely_delayed: 'border-red-200 bg-red-50 text-red-700', + blocked: 'border-zinc-900 bg-zinc-900 text-white', }; -export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) { - const firstReason = risk.reasons[0]; - const TrendIcon = risk.trend.direction === 'down' ? TrendingDown : risk.trend.direction === 'up' ? TrendingUp : TriangleAlert; - +export function XiaobaoWarningCard({ + risk, + active = false, + onClick, +}: { + risk: XiaobaoVersionRisk; + active?: boolean; + onClick: () => void; +}) { return ( + ); +} + function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
From d754585fe09ea75dd6a5aefba6d4dd50d05002c0 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 10:35:09 +0800 Subject: [PATCH 17/17] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6)?= =?UTF-8?q?:=20=E7=A7=BB=E9=99=A4=E9=A3=8E=E9=99=A9=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 29 ++------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 38354a2..dcbdcb7 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -9,11 +9,9 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; -import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight, type XiaobaoWarningRiskFilter } from '@/lib/xiaobao-warning-view'; +import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; import { formatDateTime } from '@/lib/format'; -const HIGH_RISK_LEVELS = new Set(['at_risk', 'likely_delayed', 'blocked']); - const RISK_LEVEL_LABEL: Record = { on_track: '按期', attention: '关注', @@ -45,7 +43,6 @@ function XiaobaoWarningContent() { const canManage = useHasPermission('xiaobao.warning:manage'); const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true }); const [selectedRiskId, setSelectedRiskId] = useState(null); - const [riskFilter, setRiskFilter] = useState('all'); const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState(''); const savedSnapshotKeysRef = useRef(new Set()); @@ -112,10 +109,9 @@ function XiaobaoWarningContent() { }, [projectOptions, selectedProjectId]); const filteredRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight, { - riskFilter, productId: selectedProductId || undefined, projectId: selectedProjectId || undefined, - }), [riskFilter, risksWithInsight, selectedProductId, selectedProjectId]); + }), [risksWithInsight, selectedProductId, selectedProjectId]); useEffect(() => { if (filteredRisks.length === 0) { @@ -184,11 +180,6 @@ function XiaobaoWarningContent() { onChange={setSelectedProjectId} /> -
- setRiskFilter('all')}>全部 - setRiskFilter('attention')}>需关注 - setRiskFilter('high')}>高风险 -

当前显示 {filteredRisks.length} 个风险版本,系统会自动隐藏无风险版本。

@@ -413,22 +404,6 @@ function SelectFilter({ ); } -function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { - return ( - - ); -} - function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return (