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 (
+
+
+
+
+
+
+
+
+ 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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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 (
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+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 (