86 KiB
Xiaobao Warning Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the Xiaobao Warning page with role-based visibility, deterministic version release-risk prediction, risk trend snapshots, silent-risk detection, diary evidence, and automatic AI interpretation.
Architecture: Keep risk judgment in pure frontend engines under apps/web/lib/, persist snapshots and AI interpretation caches through AppData, and call the existing NestJS AI provider layer only for structured risk interpretation. The page consumes engine outputs and stores; it does not rebuild risk rules inside components.
Tech Stack: Next.js App Router, React client components, Zustand, NestJS, AppData JSONB, shared TypeScript types, existing AI provider abstraction, Node test runner through pnpm --filter web test, Jest through pnpm --filter server test.
File Structure
- Create
apps/web/lib/role-permission-migration.ts: merge newly added default permissions into known preset roles when old AppData roles are loaded. - Create
apps/web/lib/role-permission-migration.test.ts: unit tests for role permission migration. - Modify
apps/web/lib/permissions.ts: add Xiaobao permission group and defaults. - Modify
apps/web/stores/useMemberStore.ts: call role permission migration duringfetchMembers. - Modify
apps/web/lib/server-data.ts: addxiaobao-risk-insightsandxiaobao-risk-snapshotskeys. - Modify
apps/server/src/modules/data/data-keys.ts: allow the two new AppData keys. - Create
apps/web/lib/xiaobao-risk.ts: core risk types and deterministic release forecast engine. - Create
apps/web/lib/xiaobao-risk.test.ts: core risk score, forecast, confidence, and trigger tests. - Create
apps/web/lib/xiaobao-risk-evidence.ts: version-level work item aggregation, diary/activity evidence aggregation, and silent-risk detection. - Create
apps/web/lib/xiaobao-risk-evidence.test.ts: evidence and silent-risk tests. - Create
apps/web/lib/xiaobao-risk-trend.ts: snapshot trend calculation and risk signature generation. - Create
apps/web/lib/xiaobao-risk-trend.test.ts: trend and signature tests. - Create
apps/web/stores/useXiaobaoRiskStore.ts: load/save snapshots and AI insight caches. - Create
apps/web/lib/xiaobao-risk-cache.ts: pure cache helpers and AI insight cache types. - Create
apps/web/lib/xiaobao-risk-ai.ts: decide when AI interpretation is needed and call the backend. - Create
apps/web/lib/xiaobao-risk-ai.test.ts: AI trigger and cache reuse tests. - Modify
packages/shared/src/agent.ts: add Xiaobao risk interpretation request/response types. - Create
apps/server/src/modules/ai/dto/risk-interpret.dto.ts: Nest DTO for risk interpretation. - Create
apps/server/src/modules/ai/prompts/risk-interpret.ts: system prompt and tool schema. - Modify
apps/server/src/modules/ai/ai.service.ts: addinterpretRisk. - Modify
apps/server/src/modules/ai/ai.controller.ts: addPOST /api/v1/ai/risk-interpret. - Create or modify
apps/server/src/modules/ai/ai.service.spec.ts: cover successful interpretation and provider failure. - Modify
apps/web/components/layout/Sidebar.tsx: add Xiaobao Warning nav item above Workspace. - Create
apps/web/app/xiaobao-warning/page.tsx: Xiaobao Warning page shell, data loading, filtering, automatic AI interpretation. - Create
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx: compact version risk card. - Create
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx: evidence, trend, and AI detail drawer. - Modify
docs/architecture.md,docs/decisions.md,docs/workflow.md,docs/roadmap.md,docs/agent-spec.md: document finalized feature behavior.
Task 1: Permissions And AppData Keys
Files:
-
Create:
apps/web/lib/role-permission-migration.ts -
Create:
apps/web/lib/role-permission-migration.test.ts -
Modify:
apps/web/lib/permissions.ts -
Modify:
apps/web/stores/useMemberStore.ts -
Modify:
apps/web/lib/server-data.ts -
Modify:
apps/server/src/modules/data/data-keys.ts -
Step 1: Write failing permission migration tests
Create apps/web/lib/role-permission-migration.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RoleItem } from './members';
import { mergePresetRolePermissions } from './role-permission-migration';
test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
const roles: RoleItem[] = [
{
id: 'role-pm',
name: '产品经理',
createdAt: '2024-01-01',
permissions: ['product:view'],
},
];
const result = mergePresetRolePermissions(roles);
assert.equal(result.changed, true);
assert.ok(result.roles[0].permissions.includes('product:view'));
assert.ok(result.roles[0].permissions.includes('xiaobao.warning:view'));
assert.ok(result.roles[0].permissions.includes('xiaobao.warning:manage'));
});
test('mergePresetRolePermissions keeps custom role permissions unchanged', () => {
const roles: RoleItem[] = [
{
id: 'role-custom',
name: '自定义角色',
createdAt: '2024-01-01',
permissions: ['version:view'],
},
];
const result = mergePresetRolePermissions(roles);
assert.equal(result.changed, false);
assert.deepEqual(result.roles[0].permissions, ['version:view']);
});
- Step 2: Run the permission migration test to verify it fails
Run:
pnpm --filter web test -- role-permission-migration
Expected: FAIL because apps/web/lib/role-permission-migration.ts does not exist.
- Step 3: Implement permission constants and migration helper
Modify apps/web/lib/permissions.ts by adding Xiaobao group before member/role groups:
{
module: 'xiaobao.warning',
moduleLabel: '小宝预警',
category: 'main',
actions: [
{ action: 'view', label: '查看', permission: 'xiaobao.warning:view' },
{ action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' },
],
},
Add defaults in DEFAULT_ROLE_PERMISSIONS:
'role-pm': [
...std4('product').map((a) => a.permission),
...std4('project').map((a) => a.permission),
...std4('version').map((a) => a.permission),
...std4('requirement').map((a) => a.permission),
'xiaobao.warning:view',
'xiaobao.warning:manage',
'version.req:view', 'version.req:manage',
'version.product_plan:view', 'version.product_plan:manage',
'overtime:view', 'member:view', 'role:view',
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
],
'role-dev': [
...VIEW_ONLY_BASE,
'xiaobao.warning:view',
'version.devtask:view', 'version.devtask:manage',
'version.bug:view', 'version.bug:edit',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
'overtime:view', 'overtime:create',
],
'role-test': [
...VIEW_ONLY_BASE,
'xiaobao.warning:view',
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
'overtime:view', 'overtime:create',
],
'role-design': [
...VIEW_ONLY_BASE,
'xiaobao.warning:view',
'version.ui_plan:view', 'version.ui_plan:manage',
'version.research:view', 'version.product_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
'overtime:view', 'overtime:create',
],
Create apps/web/lib/role-permission-migration.ts:
import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
let changed = false;
const next = roles.map((role) => {
const defaults = DEFAULT_ROLE_PERMISSIONS[role.id];
if (!defaults || role.permissions.includes('*')) return role;
const merged = Array.from(new Set([...role.permissions, ...defaults]));
if (merged.length === role.permissions.length) return role;
changed = true;
return { ...role, permissions: merged };
});
return { roles: next, changed };
}
- Step 4: Wire migration into member store
Modify apps/web/stores/useMemberStore.ts imports:
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
Inside fetchMembers, after migratedRoles is built and before system admin role protection, add:
const permissionMerged = mergePresetRolePermissions(migratedRoles);
const rolesWithNewDefaults = permissionMerged.roles;
const adminRole = PRESET_ROLES.find((r) => r.id === SYSTEM_ADMIN_ROLE_ID)!;
const roles = rolesWithNewDefaults.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
? rolesWithNewDefaults.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
: [adminRole, ...rolesWithNewDefaults];
const rolesChanged =
permissionMerged.changed ||
roles.length !== rolesWithNewDefaults.length ||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
Remove the old local adminRole, roles, and rolesChanged declarations to avoid duplicate identifiers.
- Step 5: Add AppData keys
Modify apps/web/lib/server-data.ts:
| 'work-activities'
| 'xiaobao-risk-insights'
| 'xiaobao-risk-snapshots'
| 'overtime';
Modify apps/server/src/modules/data/data-keys.ts:
'work-activities',
'xiaobao-risk-insights',
'xiaobao-risk-snapshots',
'overtime',
- Step 6: Run tests and commit
Run:
pnpm --filter web test -- role-permission-migration
pnpm --filter web type-check
pnpm --filter server test -- data.service
Expected: all commands PASS.
Commit:
git add apps/web/lib/permissions.ts apps/web/lib/role-permission-migration.ts apps/web/lib/role-permission-migration.test.ts apps/web/stores/useMemberStore.ts apps/web/lib/server-data.ts apps/server/src/modules/data/data-keys.ts
git commit -m "feat(小宝预警): 增加权限与数据键"
Task 2: Core Risk Forecast Engine
Files:
-
Create:
apps/web/lib/xiaobao-risk.ts -
Create:
apps/web/lib/xiaobao-risk.test.ts -
Create:
apps/web/lib/xiaobao-risk-trend.ts(minimalXiaobaoRiskSnapshottype stub; Task 3 expands it) -
Create:
apps/web/lib/xiaobao-risk-evidence.ts(minimalVersionDailyEvidencetype stub; Task 3 expands it) -
Step 1: Write failing risk engine tests
Create apps/web/lib/xiaobao-risk.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';
function version(patch: any = {}) {
return {
id: 'ver-1',
name: 'V1.0',
status: 'developing',
productName: 'FTB',
projectName: '项目管理',
expectedReleaseDate: '2026-07-03T10:00:00.000Z',
members: [{ name: '张三', role: 'frontend' }],
...patch,
};
}
function devTask(patch: Partial<DevTask> = {}): DevTask {
return {
id: 'dev-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: '开发任务',
categoryId: 'cat-frontend',
assigneeId: '张三',
priority: 'P1',
expectedStartAt: '2026-07-01T01:00:00.000Z',
expectedEndAt: '2026-07-01T09:00:00.000Z',
estimateHours: 8,
status: 'todo',
isBlocked: false,
createdBy: '张三',
createdAt: '2026-07-01T01:00:00.000Z',
updatedAt: '2026-07-01T01:00:00.000Z',
...patch,
};
}
function testCase(patch: Partial<TestCase> = {}): TestCase {
return {
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'ver-1',
title: '测试用例',
categoryId: 'cat-test',
priority: 'P1',
status: 'pending',
estimateHours: 4,
createdBy: '李四',
createdAt: '2026-07-01T01:00:00.000Z',
updatedAt: '2026-07-01T01:00:00.000Z',
...patch,
};
}
function bug(patch: Partial<Bug> = {}): Bug {
return {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'ver-1',
testCaseId: 'tc-1',
title: 'P1 Bug',
description: '阻断发版',
severity: 'major',
priority: 'P1',
reportedBy: '李四',
assigneeId: '张三',
status: 'open',
estimateHours: 4,
createdAt: '2026-07-02T01:00:00.000Z',
updatedAt: '2026-07-02T01:00:00.000Z',
...patch,
};
}
test('calcXiaobaoVersionRisk predicts delay when remaining work exceeds release date', () => {
const risk = calcXiaobaoVersionRisk({
version: version(),
plans: [],
requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
devTasks: [devTask({ estimateHours: 40 })],
testCases: [testCase({ estimateHours: 16 })],
bugs: [],
dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
snapshots: [],
now: new Date('2026-07-02T01:00:00.000Z'),
});
assert.equal(risk.riskLevel, 'likely_delayed');
assert.ok(risk.riskScore >= 70);
assert.ok(risk.delayDays > 0);
assert.ok(risk.reasons.some((r) => r.key === 'remaining_work'));
});
test('calcXiaobaoVersionRisk marks blocked when critical signals exist', () => {
const risk = calcXiaobaoVersionRisk({
version: version(),
plans: [],
requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
devTasks: [devTask({ isBlocked: true, blockReason: '接口未联调' })],
testCases: [testCase({ status: 'failed' })],
bugs: [bug({ severity: 'critical' })],
dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
snapshots: [],
now: new Date('2026-07-02T01:00:00.000Z'),
});
assert.equal(risk.riskLevel, 'blocked');
assert.ok(risk.reasons.some((r) => r.key === 'critical_bug'));
assert.ok(risk.reasons.some((r) => r.key === 'blocked_work'));
});
- Step 2: Run the risk engine test to verify it fails
Run:
pnpm --filter web test -- xiaobao-risk
Expected: FAIL because apps/web/lib/xiaobao-risk.ts does not exist.
- Step 3: Implement the core risk engine
Create apps/web/lib/xiaobao-risk-trend.ts as a type stub:
import type { XiaobaoRiskLevel } from './xiaobao-risk';
export interface XiaobaoRiskSnapshot {
versionId: string;
date: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
forecastReleaseDate?: string;
openBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
confidence: number;
createdAt: string;
}
Create apps/web/lib/xiaobao-risk-evidence.ts as a type stub:
import type { SilentRisk } from './xiaobao-risk';
export interface EvidenceItem {
id: string;
title: string;
summary: string;
occurredAt: string;
actorId?: string;
}
export interface VersionDailyEvidence {
todayDeliveries: EvidenceItem[];
todayProgress: EvidenceItem[];
todayRisks: EvidenceItem[];
progressNotes: EvidenceItem[];
needsProgressItems: EvidenceItem[];
recentActivityCount: number;
lastActivityAt?: string;
silentRisks?: SilentRisk[];
}
Create apps/web/lib/xiaobao-risk.ts:
import type { VersionPlan } from './version-plan';
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 { Bug } from './bug';
import { addWorkHours } from './work-hours';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
export type XiaobaoConfidenceLevel = 'low' | 'medium' | 'high';
export interface XiaobaoVersionRef {
id: string;
name: string;
status?: string;
productName?: string;
projectName?: string;
expectedReleaseDate?: string | null;
members?: Array<{ name: string; role: string }>;
}
export interface RiskReason {
key: string;
label: string;
severity: 'low' | 'medium' | 'high' | 'critical';
detail: string;
}
export interface SilentRisk {
key: 'no_update' | 'no_report' | 'no_activity' | 'unhandled';
days: number;
detail: string;
}
export interface XiaobaoRiskSignals {
unfinishedCount: number;
openBugCount: number;
criticalBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
daysToExpectedRelease?: number;
}
export interface XiaobaoVersionRisk {
versionId: string;
versionName: string;
productName?: string;
projectName?: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
expectedReleaseDate?: string;
forecastReleaseDate?: string;
delayDays: number;
confidence: number;
confidenceLevel: XiaobaoConfidenceLevel;
reasons: RiskReason[];
silentRisks: SilentRisk[];
signals: XiaobaoRiskSignals;
dailyEvidence: VersionDailyEvidence;
trend: {
direction: 'up' | 'down' | 'stable' | 'unknown';
delta: number;
summary: string;
};
}
export interface CalcXiaobaoVersionRiskInput {
version: XiaobaoVersionRef;
plans: VersionPlan[];
requirements: Array<{ id: string; versionId?: string | null }>;
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
dailyEvidence: VersionDailyEvidence;
snapshots: XiaobaoRiskSnapshot[];
now?: Date;
}
const BUG_FALLBACK_HOURS: Record<Bug['severity'], number> = {
critical: 8,
major: 4,
minor: 2,
trivial: 1,
};
export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): XiaobaoVersionRisk {
const now = input.now ?? new Date();
const reasons: RiskReason[] = [];
const silentRisks = input.dailyEvidence.silentRisks ?? [];
const remainingHours = calcRemainingHours(input.devTasks, input.testCases, input.bugs);
const forecastReleaseDate = remainingHours > 0 ? addWorkHours(now.toISOString(), remainingHours) : now.toISOString();
const expected = input.version.expectedReleaseDate || undefined;
const delayDays = expected ? calcDelayDays(forecastReleaseDate, expected) : 0;
const openBugs = input.bugs.filter((bug) => bug.status !== 'closed' && bug.status !== 'rejected');
const criticalBugs = openBugs.filter((bug) => bug.severity === 'critical' || bug.priority === 'P0' || bug.priority === 'P1');
const blockedDevTasks = input.devTasks.filter((task) => task.isBlocked);
const failedOrBlockedTests = input.testCases.filter((testCase) => testCase.status === 'failed' || testCase.status === 'blocked');
const unfinishedDevTasks = input.devTasks.filter((task) => task.status !== 'submitted');
const unfinishedTestCases = input.testCases.filter((testCase) => testCase.status !== 'passed');
const signals: XiaobaoRiskSignals = {
unfinishedCount: unfinishedDevTasks.length + unfinishedTestCases.length + openBugs.length,
openBugCount: openBugs.length,
criticalBugCount: criticalBugs.length,
failedTestCount: failedOrBlockedTests.length,
blockedCount: blockedDevTasks.length + failedOrBlockedTests.length,
silentRiskCount: silentRisks.length,
daysToExpectedRelease: expected ? calcDaysUntil(expected, now) : undefined,
};
if (remainingHours > 0) {
reasons.push({ key: 'remaining_work', label: '剩余工作量', severity: delayDays > 0 ? 'high' : 'medium', detail: `剩余约 ${roundTenth(remainingHours)}h` });
}
if (criticalBugs.length > 0) {
reasons.push({ key: 'critical_bug', label: '关键 Bug 未关闭', severity: 'critical', detail: `${criticalBugs.length} 个 P1/P0 或致命 Bug 未关闭` });
}
if (blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0) {
reasons.push({ key: 'blocked_work', label: '阻塞项', severity: 'critical', detail: `${blockedDevTasks.length + failedOrBlockedTests.length} 个开发/测试阻塞或失败项` });
}
if (silentRisks.length > 0) {
reasons.push({ key: 'silent_risk', label: '静默风险', severity: 'medium', detail: `${silentRisks.length} 个静默风险` });
}
const riskScore = clampRiskScore(
15 +
Math.min(35, remainingHours * 1.2) +
Math.max(0, delayDays) * 8 +
criticalBugs.length * 18 +
(blockedDevTasks.length + failedOrBlockedTests.length) * 12 +
silentRisks.length * 8,
);
const riskLevel = calcRiskLevel(riskScore, {
hasCriticalBug: criticalBugs.length > 0,
hasBlocking: blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0,
delayDays,
});
const confidence = calcConfidence(input, remainingHours);
const trend = calcTrend(input.snapshots, riskScore);
return {
versionId: input.version.id,
versionName: input.version.name,
productName: input.version.productName,
projectName: input.version.projectName,
riskScore,
riskLevel,
expectedReleaseDate: expected,
forecastReleaseDate,
delayDays,
confidence,
confidenceLevel: confidence >= 75 ? 'high' : confidence >= 45 ? 'medium' : 'low',
reasons,
silentRisks,
signals,
dailyEvidence: input.dailyEvidence,
trend,
};
}
function calcRemainingHours(devTasks: DevTask[], testCases: TestCase[], bugs: Bug[]): number {
const devHours = devTasks.reduce((sum, task) => sum + getEstimateHours(task) * ((100 - STATUS_PROGRESS[task.status]) / 100), 0);
const testHours = testCases.reduce((sum, testCase) => {
if (testCase.status === 'passed') return sum;
return sum + getTestCaseEstimateHours(testCase);
}, 0);
const bugHours = bugs.reduce((sum, bug) => {
if (bug.status === 'closed' || bug.status === 'rejected') return sum;
return sum + (bug.estimateHours ?? bug.aiEstimateHours ?? BUG_FALLBACK_HOURS[bug.severity]);
}, 0);
return roundTenth(devHours + testHours + bugHours);
}
function calcDelayDays(forecastIso: string, expectedIso: string): number {
const forecast = new Date(forecastIso);
const expected = new Date(expectedIso);
forecast.setHours(0, 0, 0, 0);
expected.setHours(0, 0, 0, 0);
return Math.max(0, Math.ceil((forecast.getTime() - expected.getTime()) / 86400000));
}
function calcDaysUntil(targetIso: string, now: Date): number {
const target = new Date(targetIso);
const current = new Date(now);
target.setHours(0, 0, 0, 0);
current.setHours(0, 0, 0, 0);
return Math.ceil((target.getTime() - current.getTime()) / 86400000);
}
function calcRiskLevel(score: number, flags: { hasCriticalBug: boolean; hasBlocking: boolean; delayDays: number }): XiaobaoRiskLevel {
if (flags.hasCriticalBug || flags.hasBlocking) return 'blocked';
if (flags.delayDays > 0 || score >= 75) return 'likely_delayed';
if (score >= 55) return 'at_risk';
if (score >= 30) return 'attention';
return 'on_track';
}
function calcConfidence(input: CalcXiaobaoVersionRiskInput, remainingHours: number): number {
let confidence = 100;
if (!input.version.expectedReleaseDate) confidence -= 30;
if (remainingHours === 0 && input.devTasks.some((task) => !task.estimateHours && !task.aiEstimateHours)) confidence -= 15;
if (input.testCases.length === 0) confidence -= 15;
if (!input.version.members || input.version.members.length === 0) confidence -= 10;
if (input.dailyEvidence.recentActivityCount === 0) confidence -= 10;
if (input.snapshots.length < 2) confidence -= 10;
return Math.max(0, Math.min(100, Math.round(confidence)));
}
function calcTrend(snapshots: XiaobaoRiskSnapshot[], riskScore: number): XiaobaoVersionRisk['trend'] {
const last = snapshots.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
if (!last) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
const delta = riskScore - last.riskScore;
if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
return { direction: 'stable', delta, summary: '风险基本稳定' };
}
function clampRiskScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
- Step 4: Run tests and commit
Run:
pnpm --filter web test -- xiaobao-risk
pnpm --filter web type-check
Expected: PASS.
Commit:
git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-evidence.ts
git commit -m "feat(小宝预警): 增加版本风险预测引擎"
Task 3: Diary Evidence, Silent Risk, Trend, And Signatures
Files:
-
Modify:
apps/web/lib/xiaobao-risk-evidence.ts -
Create:
apps/web/lib/xiaobao-risk-evidence.test.ts -
Modify:
apps/web/lib/xiaobao-risk-trend.ts -
Create:
apps/web/lib/xiaobao-risk-trend.test.ts -
Modify:
apps/web/lib/xiaobao-risk.ts -
Step 1: Write failing evidence and trend tests
Create apps/web/lib/xiaobao-risk-evidence.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence';
test('buildVersionDailyEvidence groups version activity and detects no activity', () => {
const evidence = buildVersionDailyEvidence({
versionId: 'ver-1',
workItems: [{ id: 'dev-1', versionId: 'ver-1', title: '开发', status: 'in_progress', completed: false, type: 'devTask', productName: 'FTB', projectName: '项目', versionName: 'V1', raw: { updatedAt: '2026-06-24T01:00:00.000Z' } } as any],
activities: [],
worklogs: [],
now: new Date('2026-06-29T01:00:00.000Z'),
});
assert.equal(evidence.recentActivityCount, 0);
assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_activity'));
assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_update'));
});
test('buildXiaobaoWorkItems keeps all version work instead of filtering by current user', () => {
const items = buildXiaobaoWorkItems({
plans: [{ id: 'plan-1', versionId: 'ver-1', type: 'product', title: 'Product plan', status: 'in_progress', owner: 'pm' } as any],
devTasks: [{ id: 'dev-1', requirementId: 'req-1', title: 'Develop feature', status: 'todo', priority: 'P1' } as any],
testCases: [{ id: 'tc-1', versionId: 'ver-1', title: 'Run test', status: 'pending', priority: 'P1' } as any],
bugs: [{ id: 'bug-1', versionId: 'ver-1', title: 'Fix bug', status: 'open', priority: 'P1', severity: 'major' } as any],
versionMap: new Map([['ver-1', { id: 'ver-1', name: 'V1', productName: 'FTB', projectName: 'PM' }]]),
requirementVersionMap: new Map([['req-1', 'ver-1']]),
});
assert.deepEqual(items.map((item) => item.id).sort(), ['bug-1', 'dev-1', 'plan-1', 'tc-1']);
});
Create apps/web/lib/xiaobao-risk-trend.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
test('summarizeRiskTrend detects continuous rising risk', () => {
const trend = summarizeRiskTrend([
{ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-27T01:00:00.000Z' },
{ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, riskLevel: 'attention', openBugCount: 1, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 75, createdAt: '2026-06-28T01:00:00.000Z' },
{ versionId: 'ver-1', date: '2026-06-29', riskScore: 71, riskLevel: 'at_risk', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 70, createdAt: '2026-06-29T01:00:00.000Z' },
]);
assert.equal(trend.direction, 'up');
assert.equal(trend.summary, '风险连续 3 次上升');
});
test('buildRiskSignature changes when score and bug counts change', () => {
const a = buildRiskSignature({ versionId: 'ver-1', riskScore: 35, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
const b = buildRiskSignature({ versionId: 'ver-1', riskScore: 68, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
assert.notEqual(a, b);
});
- Step 2: Run tests to verify they fail
Run:
pnpm --filter web test -- xiaobao-risk-evidence
pnpm --filter web test -- xiaobao-risk-trend
Expected: FAIL because buildVersionDailyEvidence, buildXiaobaoWorkItems, summarizeRiskTrend, and buildRiskSignature are not implemented yet.
- Step 3: Implement evidence aggregation
Replace the type stub in apps/web/lib/xiaobao-risk-evidence.ts with:
import type { TaskWorklog } from './task-worklog';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';
import type { WorkActivity } from './work-activity';
import type { WorkItem } from './workspace-engine';
import type { SilentRisk } from './xiaobao-risk';
export interface EvidenceItem {
id: string;
title: string;
summary: string;
occurredAt: string;
actorId?: string;
}
export interface VersionDailyEvidence {
todayDeliveries: EvidenceItem[];
todayProgress: EvidenceItem[];
todayRisks: EvidenceItem[];
progressNotes: EvidenceItem[];
needsProgressItems: EvidenceItem[];
recentActivityCount: number;
lastActivityAt?: string;
silentRisks: SilentRisk[];
}
export interface BuildVersionDailyEvidenceInput {
versionId: string;
workItems: WorkItem[];
activities: WorkActivity[];
worklogs: TaskWorklog[];
now?: Date;
}
export interface XiaobaoVersionContext {
id: string;
name: string;
productName: string;
projectName: string;
}
export interface BuildXiaobaoWorkItemsInput {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
versionMap: Map<string, XiaobaoVersionContext>;
requirementVersionMap: Map<string, string>;
}
const MS_PER_DAY = 86400000;
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
const items: WorkItem[] = [];
input.plans.forEach((plan) => {
const version = input.versionMap.get(plan.versionId);
items.push({
id: plan.id,
type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
title: plan.title,
status: plan.status,
completed: plan.status === 'completed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: plan.versionId,
extra: { startTime: plan.startTime, endTime: plan.endTime },
raw: plan,
});
});
input.devTasks.forEach((task) => {
const versionId = input.requirementVersionMap.get(task.requirementId) ?? '';
const version = input.versionMap.get(versionId);
items.push({
id: task.id,
type: 'devTask',
title: task.title,
status: task.status,
completed: task.status === 'submitted',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId,
priority: task.priority,
extra: { taskNo: task.taskNo, expectedStartAt: task.expectedStartAt, expectedEndAt: task.expectedEndAt },
raw: task,
});
});
input.testCases.forEach((testCase) => {
const version = input.versionMap.get(testCase.versionId);
items.push({
id: testCase.id,
type: 'testCase',
title: testCase.title,
status: testCase.status,
completed: testCase.status === 'passed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: testCase.versionId,
priority: testCase.priority,
extra: { caseNo: testCase.caseNo },
raw: testCase,
});
});
input.bugs.forEach((bug) => {
const version = input.versionMap.get(bug.versionId);
items.push({
id: bug.id,
type: 'bug',
title: bug.title,
status: bug.status,
completed: bug.status === 'closed' || bug.status === 'rejected',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: bug.versionId,
priority: bug.priority,
extra: { bugNo: bug.bugNo, severity: bug.severity },
raw: bug,
});
});
return items;
}
export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
const now = input.now ?? new Date();
const today = toDateKey(now);
const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
const sourceIds = new Set(versionItems.map((item) => item.id));
const activities = input.activities.filter((activity) => sourceIds.has(activity.sourceId));
const worklogs = input.worklogs.filter((worklog) => sourceIds.has(worklog.taskId));
const recentActivities = activities.filter((activity) => daysBetween(activity.occurredAt, now) <= 3);
const todayActivities = activities.filter((activity) => activity.date === today);
const evidence: VersionDailyEvidence = {
todayDeliveries: toEvidence(todayActivities.filter((activity) => activity.category === 'delivery')),
todayProgress: toEvidence(todayActivities.filter((activity) => activity.category === 'progress')),
todayRisks: toEvidence(todayActivities.filter((activity) => activity.category === 'risk')),
progressNotes: [
...toEvidence(todayActivities.filter((activity) => activity.category === 'note')),
...worklogs.filter((worklog) => worklog.date === today).map((worklog) => ({
id: worklog.id,
title: '进展说明',
summary: worklog.workContent,
occurredAt: worklog.createdAt,
actorId: worklog.userId,
})),
],
needsProgressItems: [],
recentActivityCount: recentActivities.length,
lastActivityAt: activities.map((activity) => activity.occurredAt).sort().reverse()[0],
silentRisks: [],
};
evidence.needsProgressItems = versionItems
.filter((item) => !item.completed && ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(item.status))
.filter((item) => daysBetween(getUpdatedAt(item), now) >= 3)
.map((item) => ({ id: item.id, title: item.title, summary: `已 ${daysBetween(getUpdatedAt(item), now)} 天无人处理`, occurredAt: getUpdatedAt(item) }));
const noActivityDays = evidence.lastActivityAt ? daysBetween(evidence.lastActivityAt, now) : 999;
const lastWorklogAt = worklogs.map((worklog) => worklog.createdAt).sort().reverse()[0];
const noReportDays = lastWorklogAt ? daysBetween(lastWorklogAt, now) : 999;
const maxNoUpdateDays = Math.max(0, ...versionItems.map((item) => daysBetween(getUpdatedAt(item), now)));
if (maxNoUpdateDays >= 5) evidence.silentRisks.push({ key: 'no_update', days: maxNoUpdateDays, detail: `${maxNoUpdateDays} 天无更新` });
if (noReportDays >= 8) evidence.silentRisks.push({ key: 'no_report', days: noReportDays, detail: `${noReportDays} 天无日报` });
if (noActivityDays >= 4) evidence.silentRisks.push({ key: 'no_activity', days: noActivityDays, detail: `${noActivityDays} 天无活动` });
if (evidence.needsProgressItems.length > 0) evidence.silentRisks.push({ key: 'unhandled', days: 3, detail: `${evidence.needsProgressItems.length} 项超过 3 天无人处理` });
return evidence;
}
function toEvidence(activities: WorkActivity[]): EvidenceItem[] {
return activities.map((activity) => ({
id: activity.id,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
actorId: activity.actorId,
}));
}
function getUpdatedAt(item: WorkItem): string {
const raw = item.raw as Record<string, unknown>;
return typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date(0).toISOString();
}
function daysBetween(iso: string | undefined, now: Date): number {
if (!iso) return 999;
const value = new Date(iso).getTime();
if (!Number.isFinite(value)) return 999;
return Math.floor((now.getTime() - value) / MS_PER_DAY);
}
function toDateKey(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
- Step 4: Implement trend and signatures
Replace the type stub in apps/web/lib/xiaobao-risk-trend.ts with:
import type { XiaobaoRiskLevel } from './xiaobao-risk';
export interface XiaobaoRiskSnapshot {
versionId: string;
date: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
forecastReleaseDate?: string;
openBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
confidence: number;
createdAt: string;
}
export interface RiskTrendSummary {
direction: 'up' | 'down' | 'stable' | 'unknown';
delta: number;
summary: string;
}
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
const ordered = snapshots.slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
if (ordered.length < 2) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
const last = ordered[ordered.length - 1];
const first = ordered[Math.max(0, ordered.length - 3)];
const delta = last.riskScore - first.riskScore;
const recent = ordered.slice(-3);
const rising = recent.length === 3 && recent[0].riskScore < recent[1].riskScore && recent[1].riskScore < recent[2].riskScore;
const falling = recent.length === 3 && recent[0].riskScore > recent[1].riskScore && recent[1].riskScore > recent[2].riskScore;
if (rising) return { direction: 'up', delta, summary: '风险连续 3 次上升' };
if (falling) return { direction: 'down', delta, summary: '风险连续 3 次下降' };
if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
return { direction: 'stable', delta, summary: '风险基本稳定' };
}
export function summarizeRiskTrendWithCurrent(snapshots: XiaobaoRiskSnapshot[], current: XiaobaoRiskSnapshot): RiskTrendSummary {
return summarizeRiskTrend([...snapshots, current]);
}
export function buildRiskSignature(input: {
versionId: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
forecastReleaseDate?: string;
openBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
confidence: number;
}): string {
return [
input.versionId,
input.riskScore,
input.riskLevel,
input.forecastReleaseDate ?? '',
input.openBugCount,
input.failedTestCount,
input.blockedCount,
input.silentRiskCount,
input.confidence,
].join('|');
}
- Step 5: Wire trend types into core engine
Modify apps/web/lib/xiaobao-risk.ts imports and replace the local trend summary logic with summarizeRiskTrendWithCurrent:
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
Replace the trend calculation in calcXiaobaoVersionRisk after confidence is calculated:
const trend = summarizeRiskTrendWithCurrent(input.snapshots, {
versionId: input.version.id,
date: now.toISOString().slice(0, 10),
riskScore,
riskLevel,
forecastReleaseDate,
openBugCount: signals.criticalBugCount,
failedTestCount: signals.failedTestCount,
blockedCount: signals.blockedCount,
silentRiskCount: signals.silentRiskCount,
confidence,
createdAt: now.toISOString(),
});
Then delete the old local calcTrend helper from apps/web/lib/xiaobao-risk.ts.
- Step 6: Run tests and commit
Run:
pnpm --filter web test -- xiaobao-risk-evidence
pnpm --filter web test -- xiaobao-risk-trend
pnpm --filter web test -- xiaobao-risk
pnpm --filter web type-check
Expected: PASS.
Commit:
git add apps/web/lib/xiaobao-risk-evidence.ts apps/web/lib/xiaobao-risk-evidence.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-trend.test.ts apps/web/lib/xiaobao-risk.ts
git commit -m "feat(小宝预警): 增加日报证据与风险趋势"
Task 4: Snapshots And Insight Cache Store
Files:
-
Create:
apps/web/stores/useXiaobaoRiskStore.ts -
Create:
apps/web/lib/xiaobao-risk-cache.ts -
Create:
apps/web/lib/xiaobao-risk-cache.test.ts -
Modify:
apps/web/lib/xiaobao-risk.ts -
Step 1: Write failing cache tests
Create apps/web/lib/xiaobao-risk-cache.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { findCachedInsight, upsertInsight, upsertDailySnapshot } from './xiaobao-risk-cache';
test('findCachedInsight returns matching signature only', () => {
const rows = [{ versionId: 'ver-1', riskSignature: 'a', insight: { summary: '旧解读', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' }];
assert.equal(findCachedInsight(rows, 'ver-1', 'a')?.insight.summary, '旧解读');
assert.equal(findCachedInsight(rows, 'ver-1', 'b'), undefined);
});
test('upsertDailySnapshot keeps one snapshot per version and date', () => {
const rows = upsertDailySnapshot([], { versionId: 'ver-1', date: '2026-06-29', riskScore: 50, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-29T01:00:00.000Z' });
const next = upsertDailySnapshot(rows, { ...rows[0], riskScore: 70, createdAt: '2026-06-29T02:00:00.000Z' });
assert.equal(next.length, 1);
assert.equal(next[0].riskScore, 70);
});
test('upsertInsight replaces existing version signature pair', () => {
const first = upsertInsight([], { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'A', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' });
const second = upsertInsight(first, { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'B', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T02:00:00.000Z' }, generatedAt: '2026-06-29T02:00:00.000Z' });
assert.equal(second.length, 1);
assert.equal(second[0].insight.summary, 'B');
});
- Step 2: Run cache tests to verify failure
Run:
pnpm --filter web test -- xiaobao-risk-cache
Expected: FAIL because apps/web/lib/xiaobao-risk-cache.ts does not exist.
- Step 3: Implement cache helpers and store
Create apps/web/lib/xiaobao-risk-cache.ts:
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))];
}
Create apps/web/stores/useXiaobaoRiskStore.ts:
'use client';
import { create } from 'zustand';
import { loadServerData, saveServerData } from '@/lib/server-data';
import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import type { XiaobaoRiskInsightCacheItem } from '@/lib/xiaobao-risk-cache';
import { upsertDailySnapshot, upsertInsight } from '@/lib/xiaobao-risk-cache';
interface XiaobaoRiskState {
snapshots: XiaobaoRiskSnapshot[];
insights: XiaobaoRiskInsightCacheItem[];
fetchRiskData: () => Promise<void>;
saveSnapshot: (snapshot: XiaobaoRiskSnapshot) => Promise<void>;
saveInsight: (insight: XiaobaoRiskInsightCacheItem) => Promise<void>;
}
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
snapshots: [],
insights: [],
fetchRiskData: async () => {
const [snapshots, insights] = await Promise.all([
loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots'),
loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights'),
]);
set({ snapshots: snapshots ?? [], insights: insights ?? [] });
},
saveSnapshot: async (snapshot) => {
const snapshots = upsertDailySnapshot(get().snapshots, snapshot);
set({ snapshots });
await saveServerData('xiaobao-risk-snapshots', snapshots);
},
saveInsight: async (insight) => {
const insights = upsertInsight(get().insights, insight);
set({ insights });
await saveServerData('xiaobao-risk-insights', insights);
},
}));
- Step 4: Expose optional AI insight on risk view model
Modify apps/web/lib/xiaobao-risk.ts imports:
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
Add the optional field to XiaobaoVersionRisk:
aiInsight?: XiaobaoRiskInsight;
- Step 5: Run tests and commit
Run:
pnpm --filter web test -- xiaobao-risk-cache
pnpm --filter web type-check
Expected: PASS.
Commit:
git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk-cache.ts apps/web/lib/xiaobao-risk-cache.test.ts apps/web/stores/useXiaobaoRiskStore.ts
git commit -m "feat(小宝预警): 增加趋势与解读缓存"
Task 5: Backend AI Risk Interpretation
Files:
-
Modify:
packages/shared/src/agent.ts -
Create:
apps/server/src/modules/ai/dto/risk-interpret.dto.ts -
Create:
apps/server/src/modules/ai/prompts/risk-interpret.ts -
Modify:
apps/server/src/modules/ai/ai.service.ts -
Modify:
apps/server/src/modules/ai/ai.controller.ts -
Modify:
apps/server/src/modules/ai/ai.service.spec.ts -
Step 1: Add shared request and response types
Modify packages/shared/src/agent.ts after decompose response types:
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;
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';
}
- Step 2: Write failing server test
Add to apps/server/src/modules/ai/ai.service.spec.ts:
it('interprets version risk through the active provider', async () => {
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({
callTool: jest.fn().mockResolvedValue({
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,
}),
}),
getActiveModel: jest.fn().mockResolvedValue('test-model'),
} as any;
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('预计延期');
});
- Step 3: Run server test to verify failure
Run:
pnpm --filter server test -- ai.service
Expected: FAIL because interpretRisk does not exist.
- Step 4: Implement DTO, prompt, service, and controller
Create apps/server/src/modules/ai/dto/risk-interpret.dto.ts:
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class RiskReasonDto {
@IsString() key!: string;
@IsString() label!: string;
@IsString() severity!: string;
@IsString() detail!: string;
}
class RiskDailyEvidenceDto {
@IsArray() todayDeliveries!: string[];
@IsArray() todayProgress!: string[];
@IsArray() todayRisks!: string[];
@IsArray() progressNotes!: string[];
@IsArray() 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;
@IsString() riskLevel!: string;
@IsOptional() @IsString() expectedReleaseDate?: string;
@IsOptional() @IsString() forecastReleaseDate?: string;
@IsNumber() delayDays!: number;
@IsNumber() confidence!: number;
@ValidateNested() @Type(() => RiskSignalsDto) signals!: RiskSignalsDto;
@IsString() trendSummary!: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => RiskReasonDto) reasons!: RiskReasonDto[];
@IsArray() silentRisks!: Array<{ key: string; days: number; detail: string }>;
@ValidateNested() @Type(() => RiskDailyEvidenceDto) dailyEvidence!: RiskDailyEvidenceDto;
}
Create apps/server/src/modules/ai/prompts/risk-interpret.ts:
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 低,必须提醒“预测可信度较低,需补充数据”。`;
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'],
};
Modify apps/server/src/modules/ai/ai.service.ts imports:
import {
RISK_INTERPRET_SYSTEM_PROMPT,
RISK_INTERPRET_TOOL_NAME,
RISK_INTERPRET_TOOL_DESCRIPTION,
RISK_INTERPRET_TOOL_INPUT_SCHEMA,
} from './prompts/risk-interpret';
import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError, AgentRiskInsight } from '@ftb/shared';
Add method to AiService:
async interpretRisk(req: AgentRiskInterpretRequest): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
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 = toolResp.toolInput as AgentRiskInsight;
if (!result.summary || !Array.isArray(result.why) || !Array.isArray(result.suggestedActions)) {
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) {
return { ok: false, error: `AI 服务调用失败:${e.message}`, code: 'API_ERROR' };
}
}
Modify apps/server/src/modules/ai/ai.controller.ts:
import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type { AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';
@Post('risk-interpret')
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
return this.aiService.interpretRisk(dto as any);
}
- Step 5: Run tests and commit
Run:
pnpm --filter server test -- ai.service
pnpm --filter server type-check
pnpm --filter web type-check
Expected: PASS.
Commit:
git add packages/shared/src/agent.ts apps/server/src/modules/ai/dto/risk-interpret.dto.ts apps/server/src/modules/ai/prompts/risk-interpret.ts apps/server/src/modules/ai/ai.service.ts apps/server/src/modules/ai/ai.controller.ts apps/server/src/modules/ai/ai.service.spec.ts
git commit -m "feat(小宝预警): 增加 AI 风险解读接口"
Task 6: Client AI Trigger And Cache Orchestration
Files:
-
Create:
apps/web/lib/xiaobao-risk-ai.ts -
Create:
apps/web/lib/xiaobao-risk-ai.test.ts -
Step 1: Write failing AI trigger tests
Create apps/web/lib/xiaobao-risk-ai.test.ts:
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldRequestRiskInsight } from './xiaobao-risk-ai';
test('shouldRequestRiskInsight skips on_track', () => {
assert.equal(shouldRequestRiskInsight({ riskLevel: 'on_track', riskScore: 10, confidence: 90 } as any, undefined), false);
});
test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
assert.equal(
shouldRequestRiskInsight(
{ riskLevel: 'attention', riskScore: 68, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', signals: { unfinishedCount: 0, openBugCount: 0, criticalBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } } as any,
{ riskScore: 35, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } as any,
),
true,
);
});
test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
const current = {
riskLevel: 'attention',
riskScore: 52,
confidence: 80,
forecastReleaseDate: '2026-07-04T10:00:00.000Z',
signals: {
unfinishedCount: 4,
openBugCount: 3,
criticalBugCount: 3,
failedTestCount: 1,
blockedCount: 1,
silentRiskCount: 1,
daysToExpectedRelease: 1,
},
} as any;
const previous = {
riskScore: 50,
confidence: 80,
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
openBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
} as any;
assert.equal(shouldRequestRiskInsight(current, previous), true);
});
test('shouldRequestRiskInsight triggers high risk levels', () => {
assert.equal(shouldRequestRiskInsight({ riskLevel: 'at_risk', riskScore: 60, confidence: 80 } as any, undefined), true);
assert.equal(shouldRequestRiskInsight({ riskLevel: 'likely_delayed', riskScore: 80, confidence: 80 } as any, undefined), true);
assert.equal(shouldRequestRiskInsight({ riskLevel: 'blocked', riskScore: 90, confidence: 80 } as any, undefined), true);
});
- Step 2: Run trigger test to verify failure
Run:
pnpm --filter web test -- xiaobao-risk-ai
Expected: FAIL because apps/web/lib/xiaobao-risk-ai.ts does not exist.
- Step 3: Implement AI trigger and request mapper
Create apps/web/lib/xiaobao-risk-ai.ts:
import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';
import { api } from './api';
import type { XiaobaoVersionRisk } from './xiaobao-risk';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
import { buildRiskSignature } from './xiaobao-risk-trend';
import { findCachedInsight } from './xiaobao-risk-cache';
type RiskInsightCurrent = Pick<XiaobaoVersionRisk, 'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals'>;
type RiskInsightPrevious = Pick<XiaobaoRiskSnapshot, 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'openBugCount' | 'failedTestCount' | 'blockedCount' | 'silentRiskCount'>;
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 releaseIsTomorrowWithUnfinishedWork =
current.signals.daysToExpectedRelease !== undefined &&
current.signals.daysToExpectedRelease <= 1 &&
current.signals.unfinishedCount > 0;
if (!previous) return releaseIsTomorrowWithUnfinishedWork;
const scoreDelta = previous ? current.riskScore - previous.riskScore : 0;
const confidenceDelta = previous ? previous.confidence - current.confidence : 0;
const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate
? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime()
: 0;
return (
scoreDelta >= 15 ||
confidenceDelta >= 15 ||
forecastDelayMs >= 86400000 ||
current.signals.criticalBugCount > previous.openBugCount ||
current.signals.failedTestCount > previous.failedTestCount ||
current.signals.blockedCount > previous.blockedCount ||
current.signals.silentRiskCount > previous.silentRiskCount ||
releaseIsTomorrowWithUnfinishedWork
);
}
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
return buildRiskSignature({
versionId: risk.versionId,
riskScore: risk.riskScore,
riskLevel: risk.riskLevel,
forecastReleaseDate: risk.forecastReleaseDate,
openBugCount: risk.signals.criticalBugCount,
failedTestCount: risk.signals.failedTestCount,
blockedCount: risk.signals.blockedCount,
silentRiskCount: risk.signals.silentRiskCount,
confidence: risk.confidence,
});
}
export function getReusableInsight(cache: XiaobaoRiskInsightCacheItem[], risk: XiaobaoVersionRisk): XiaobaoRiskInsightCacheItem | undefined {
return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
}
export async function requestRiskInsight(risk: XiaobaoVersionRisk): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
const payload: AgentRiskInterpretRequest = {
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,
silentRisks: risk.silentRisks,
dailyEvidence: {
todayDeliveries: risk.dailyEvidence.todayDeliveries.map((item) => item.summary),
todayProgress: risk.dailyEvidence.todayProgress.map((item) => item.summary),
todayRisks: risk.dailyEvidence.todayRisks.map((item) => item.summary),
progressNotes: risk.dailyEvidence.progressNotes.map((item) => item.summary),
needsProgressItems: risk.dailyEvidence.needsProgressItems.map((item) => item.summary),
recentActivityCount: risk.dailyEvidence.recentActivityCount,
lastActivityAt: risk.dailyEvidence.lastActivityAt,
},
};
return api.post('/ai/risk-interpret', payload);
}
- Step 4: Run tests and commit
Run:
pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check
Expected: PASS.
Commit:
git add apps/web/lib/xiaobao-risk-ai.ts apps/web/lib/xiaobao-risk-ai.test.ts
git commit -m "feat(小宝预警): 增加 AI 解读触发策略"
Task 7: Xiaobao Warning Page And Navigation
Files:
-
Modify:
apps/web/components/layout/Sidebar.tsx -
Create:
apps/web/app/xiaobao-warning/page.tsx -
Create:
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx -
Create:
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx -
Step 1: Add navigation item
Modify apps/web/components/layout/Sidebar.tsx imports:
import { AlertTriangle, Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
Add item before 与我相关:
{ label: '小宝预警', path: '/xiaobao-warning', icon: AlertTriangle, permission: 'xiaobao.warning:view' },
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
- Step 2: Create card component
Create apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx:
'use client';
import { AlertTriangle, ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
const LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
on_track: '可按期',
attention: '关注',
at_risk: '高风险',
likely_delayed: '预计延期',
blocked: '阻塞',
};
const LEVEL_STYLE: Record<XiaobaoVersionRisk['riskLevel'], string> = {
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-red-100 text-red-800 border-red-300',
};
export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) {
const TrendIcon = risk.trend.direction === 'up' ? ArrowUpRight : risk.trend.direction === 'down' ? ArrowDownRight : ArrowRight;
return (
<button onClick={onClick} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-left transition-colors hover:border-[var(--accent)]">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-orange-50 text-orange-600">
<AlertTriangle className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
<span className={`rounded-md border px-2 py-0.5 text-[11px] font-medium ${LEVEL_STYLE[risk.riskLevel]}`}>{LEVEL_LABEL[risk.riskLevel]}</span>
</div>
<p className="mt-1 truncate text-[11px] text-[var(--ink-muted)]">{[risk.productName, risk.projectName].filter(Boolean).join(' / ')}</p>
<div className="mt-3 grid grid-cols-4 gap-3 text-[11px]">
<Metric label="风险分" value={String(risk.riskScore)} />
<Metric label="预计可发" value={risk.forecastReleaseDate?.slice(0, 10) ?? '-'} />
<Metric label="延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '无'} />
<Metric label="置信度" value={`${risk.confidence}%`} />
</div>
<div className="mt-3 flex items-center gap-1.5 text-[11px] text-[var(--ink-soft)]">
<TrendIcon className="h-3.5 w-3.5" />
<span>{risk.trend.summary}</span>
</div>
{risk.reasons[0] && <p className="mt-2 line-clamp-2 text-[12px] text-[var(--ink-soft)]">{risk.reasons[0].detail}</p>}
</div>
</div>
</button>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
<p className="mt-0.5 font-semibold tabular-nums text-[var(--ink)]">{value}</p>
</div>
);
}
- Step 3: Create drawer component
Create apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx:
'use client';
import { X } from 'lucide-react';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
export function XiaobaoWarningDrawer({ risk, onClose, onNavigate }: { risk: XiaobaoVersionRisk; onClose: () => void; onNavigate: () => void }) {
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
<div className="h-full w-full max-w-md border-l border-[var(--line)] bg-[var(--bg)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex h-14 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div>
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h2>
<p className="text-[11px] text-[var(--ink-muted)]">小宝预警详情</p>
</div>
<button onClick={onClose} className="rounded p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-3.5rem)] overflow-y-auto p-5">
<Section title="预测依据">
{risk.reasons.length === 0 ? <Empty text="暂无明显风险" /> : risk.reasons.map((reason) => (
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
<p className="text-[12px] font-medium text-[var(--ink)]">{reason.label}</p>
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
</div>
))}
</Section>
<Section title="日报证据">
{[...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].length === 0
? <Empty text="暂无近期日报证据" />
: [...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].map((item) => (
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-soft)]">{item.summary}</div>
))}
</Section>
<Section title="静默风险">
{risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item) => (
<div key={item.key} className="rounded-lg border border-orange-200 bg-orange-50 p-3 text-[12px] text-orange-800">{item.detail}</div>
))}
</Section>
<Section title="风险趋势">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
{risk.trend.summary}
</div>
</Section>
<button onClick={onNavigate} className="mt-4 h-9 w-full rounded-lg bg-[var(--accent)] text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]">打开版本详情</button>
</div>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mb-5">
<h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
<div className="space-y-2">{children}</div>
</section>
);
}
function Empty({ text }: { text: string }) {
return <div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">{text}</div>;
}
- Step 4: Create page
Create apps/web/app/xiaobao-warning/page.tsx:
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
export default function XiaobaoWarningPage() {
return (
<RouteGuard permission="xiaobao.warning:view">
<XiaobaoWarningContent />
</RouteGuard>
);
}
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 [selected, setSelected] = useState<XiaobaoVersionRisk | null>(null);
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 risks = useMemo(() => {
const versions = flattenVersions(overview)
.filter((version) => ['planned', 'developing', 'paused'].includes(version.status))
.filter((version) => canManage || (version.members ?? []).some((member) => member.name === user?.name));
const versionMap = new Map(versions.map((v) => [v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }]));
const requirementVersionMap = new Map<string, string>();
requirements.forEach((requirement) => { if (requirement.versionId) requirementVersionMap.set(requirement.id, requirement.versionId); });
const workItems = buildXiaobaoWorkItems({
plans,
devTasks,
testCases,
bugs,
versionMap,
requirementVersionMap,
});
return versions.map((version) => {
const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id);
const reqIds = new Set(versionRequirements.map((requirement) => requirement.id));
const evidence = buildVersionDailyEvidence({ versionId: version.id, workItems, activities, worklogs });
return calcXiaobaoVersionRisk({
version,
plans: plans.filter((plan) => plan.versionId === version.id),
requirements: versionRequirements,
devTasks: devTasks.filter((task) => reqIds.has(task.requirementId)),
testCases: testCases.filter((testCase) => testCase.versionId === version.id),
bugs: bugs.filter((bug) => bug.versionId === version.id),
dailyEvidence: evidence,
snapshots: [],
});
}).sort((a, b) => b.riskScore - a.riskScore);
}, [overview, canManage, user?.name, requirements, plans, devTasks, testCases, bugs, activities, worklogs]);
return (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div>
<h1 className="text-[15px] font-semibold text-[var(--ink)]">小宝预警</h1>
<p className="text-[11px] text-[var(--ink-muted)]">{canManage ? '管理视角:全部版本' : '个人视角:我参与的版本'}</p>
</div>
<span className="text-[12px] text-[var(--ink-muted)]">{risks.length} 个版本</span>
</header>
<main className="flex-1 overflow-y-auto p-5">
{risks.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">暂无小宝预警</div>
) : (
<div className="grid gap-3 xl:grid-cols-2">
{risks.map((risk) => <XiaobaoWarningCard key={risk.versionId} risk={risk} onClick={() => setSelected(risk)} />)}
</div>
)}
</main>
{selected && (
<XiaobaoWarningDrawer
risk={selected}
onClose={() => setSelected(null)}
onNavigate={() => router.push(`/versions/${selected.versionId}`)}
/>
)}
</div>
);
}
- Step 5: Run UI checks and commit
Run:
pnpm --filter web type-check
pnpm --filter web test -- xiaobao-risk
curl http://localhost:3000/xiaobao-warning
Expected: type-check PASS, tests PASS, curl returns HTML with HTTP 200 when dev server is running.
Commit:
git add apps/web/components/layout/Sidebar.tsx apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
git commit -m "feat(小宝预警): 增加预警页面"
Task 8: Automatic AI Interpretation In The Page
Files:
-
Modify:
apps/web/app/xiaobao-warning/page.tsx -
Modify:
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx -
Modify:
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx -
Step 1: Wire store, cache, and AI calls into page
Modify apps/web/app/xiaobao-warning/page.tsx imports:
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { buildRiskInsightSignature, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
Inside XiaobaoWarningContent:
const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
useEffect(() => { fetchRiskData(); }, [fetchRiskData]);
Pass snapshots into risk calculation:
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id),
Add effect after risks:
useEffect(() => {
const today = new Date().toISOString().slice(0, 10);
risks.forEach((risk) => {
saveSnapshot({
versionId: risk.versionId,
date: today,
riskScore: risk.riskScore,
riskLevel: risk.riskLevel,
forecastReleaseDate: risk.forecastReleaseDate,
openBugCount: risk.signals.criticalBugCount,
failedTestCount: risk.signals.failedTestCount,
blockedCount: risk.signals.blockedCount,
silentRiskCount: risk.signals.silentRiskCount,
confidence: risk.confidence,
createdAt: new Date().toISOString(),
}).catch(() => {});
});
}, [risks, saveSnapshot]);
useEffect(() => {
risks.forEach((risk) => {
const cached = getReusableInsight(insights, risk);
const today = new Date().toISOString().slice(0, 10);
const previous = snapshots
.filter((snapshot) => snapshot.versionId === risk.versionId && snapshot.date < today)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
if (cached || !shouldRequestRiskInsight(risk, previous)) return;
requestRiskInsight(risk).then((res) => {
if (!res.ok) return;
saveInsight({
versionId: risk.versionId,
riskSignature: buildRiskInsightSignature(risk),
insight: res.result,
generatedAt: new Date().toISOString(),
providerInfo: { model: res.meta.model },
}).catch(() => {});
}).catch(() => {});
});
}, [risks, insights, snapshots, saveInsight]);
Map cached insight into selected risk by extending local state with cache lookup in render:
const risksWithInsight = useMemo(
() => risks.map((risk) => ({ ...risk, aiInsight: getReusableInsight(insights, risk)?.insight })),
[risks, insights],
);
const selectedWithInsight = selected
? { ...selected, aiInsight: getReusableInsight(insights, selected)?.insight }
: null;
Use risksWithInsight for card rendering and selectedWithInsight for drawer rendering:
{risksWithInsight.map((risk) => <XiaobaoWarningCard key={risk.versionId} risk={risk} onClick={() => setSelected(risk)} />)}
{selectedWithInsight && (
<XiaobaoWarningDrawer
risk={selectedWithInsight}
onClose={() => setSelected(null)}
onNavigate={() => router.push(`/versions/${selectedWithInsight.versionId}`)}
/>
)}
- Step 2: Show AI insight on card and drawer
Modify XiaobaoWarningCard after first reason:
{risk.aiInsight?.summary && <p className="mt-2 line-clamp-2 text-[12px] text-[var(--accent)]">{risk.aiInsight.summary}</p>}
Modify XiaobaoWarningDrawer before the navigation button:
<Section title="小宝建议">
{risk.aiInsight ? (
<div className="space-y-2">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
<p className="text-[12px] font-medium text-[var(--ink)]">{risk.aiInsight.summary}</p>
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
</div>
{risk.aiInsight.suggestedActions.map((action) => (
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-soft)]">{action}</div>
))}
</div>
) : (
<Empty text="规则预警已生成,AI 解读会在触发条件满足时自动补充" />
)}
</Section>
- Step 3: Run verification and commit
Run:
pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check
Expected: PASS.
Commit:
git add apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
git commit -m "feat(小宝预警): 自动生成 AI 风险解读"
Task 9: Documentation Updates
Files:
-
Modify:
docs/architecture.md -
Modify:
docs/decisions.md -
Modify:
docs/workflow.md -
Modify:
docs/roadmap.md -
Modify:
docs/agent-spec.md -
Step 1: Update architecture
Add a section to docs/architecture.md near “AI Agent 层”:
## 小宝预警层
小宝预警是版本级风险预测能力,位于主导航“工作区 / 小宝预警”。它不替代版本健康度,而是在版本维度回答“能不能按期发版、为什么有风险、预计延期到多久、风险趋势如何”。
核心规则位于前端纯函数引擎:
- `xiaobao-risk.ts`:版本风险分、等级、预计可发日期、置信度。
- `xiaobao-risk-evidence.ts`:日报和工作活动证据、静默风险。
- `xiaobao-risk-trend.ts`:风险快照、趋势和 AI 解读签名。
AI 只解释规则结果,不直接修改业务实体。AI 解读缓存写入 `xiaobao-risk-insights`,风险趋势快照写入 `xiaobao-risk-snapshots`。
- Step 2: Update decisions
Append to docs/decisions.md:
## 33. 小宝预警规则优先,AI 只解释
**问题**:如果直接让 AI 判断版本能否发版,模型可能忽略系统内的任务、Bug、日报和权限事实,导致不可追溯的结论。
**决策**:小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势和静默风险。AI 只在规则触发后解释这些事实,不直接修改任何业务数据。
**理由**:规则结果可测试、可追溯,AI 文案可提升可读性但不能替代系统判断。
- Step 3: Update workflow
Add to docs/workflow.md:
## 小宝预警工作流
小宝预警位于主导航“工作区 / 小宝预警”,显示在“与我相关”上方。可见范围由角色权限控制:
- `xiaobao.warning:manage`:查看所有未结束版本预警。
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的版本。
页面打开时会计算当前可见版本风险,并写入当天风险快照。风险达到 `at_risk`、`likely_delayed`、`blocked`,或 `attention` 出现明显风险变化时,会自动生成 AI 解读。AI 解读只写入缓存,不修改版本或任务。
- Step 4: Update roadmap and agent spec
In docs/roadmap.md, replace the V3.2 Risk Watch Agent placeholder with:
### V3.2 — 小宝预警 / Risk Watch Agent
小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版不做后台定时 Agent,风险快照在用户打开页面时写入。
In docs/agent-spec.md, replace “Agent 2:Risk Watch Agent(风险预警)— 待规划” with:
### Agent 2:Risk Watch Agent(小宝预警解释)
**目的**:解释小宝预警规则引擎输出的版本风险结果,生成项目经理可读的风险原因、延期预测、建议发版窗口和处理动作。
**输入**:版本上下文、规则风险结果、风险趋势、静默风险、日报证据和置信度。
**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。
**权限**:读规则结果和压缩证据;写 `xiaobao-risk-insights` 缓存。不得修改 Version、DevTask、TestCase、Bug、Requirement、Member。
**失败回退**:AI 不可用时保留规则预警,前端显示“AI 解读暂不可用”。
- Step 5: Run doc scan and commit
Run:
$terms = @('TO' + 'DO', 'TB' + 'D', '待' + '定', '占' + '位', '未' + '确定')
rg -n ($terms -join '|') docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md
Expected: no matches introduced by this task.
Commit:
git add docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md
git commit -m "docs(小宝预警): 更新架构与 Agent 规范"
Task 10: Final Verification
Files:
-
No new files.
-
Step 1: Run full web verification
Run:
pnpm --filter web test
pnpm --filter web type-check
Expected: both commands PASS.
- Step 2: Run server verification
Run:
pnpm --filter server test
pnpm --filter server type-check
pnpm --filter server exec prisma validate --schema prisma/schema.prisma
Expected: all commands PASS.
- Step 3: Check route manually
Run when the web dev server is already running:
curl http://localhost:3000/xiaobao-warning
Expected: HTTP 200 with HTML content.
- Step 4: Inspect git status
Run:
git status --short
Expected: only pre-existing unrelated local changes remain, or a clean working tree if this feature was implemented in an isolated worktree.
- Step 5: Final commit if verification-only fixes were needed
If verification required small fixes, commit only those feature files:
git add <files fixed during verification>
git commit -m "fix(小宝预警): 修复验证问题"
Expected: commit succeeds, or no commit is needed because no fixes were required.