Files
ftb-project-management/docs/superpowers/plans/2026-06-29-xiaobao-warning.md
2026-06-29 18:14:22 +08:00

2395 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 during `fetchMembers`.
- Modify `apps/web/lib/server-data.ts`: add `xiaobao-risk-insights` and `xiaobao-risk-snapshots` keys.
- 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`: add `interpretRisk`.
- Modify `apps/server/src/modules/ai/ai.controller.ts`: add `POST /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`:
```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:
```bash
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:
```ts
{
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`:
```ts
'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`:
```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:
```ts
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
```
Inside `fetchMembers`, after `migratedRoles` is built and before system admin role protection, add:
```ts
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`:
```ts
| 'work-activities'
| 'xiaobao-risk-insights'
| 'xiaobao-risk-snapshots'
| 'overtime';
```
Modify `apps/server/src/modules/data/data-keys.ts`:
```ts
'work-activities',
'xiaobao-risk-insights',
'xiaobao-risk-snapshots',
'overtime',
```
- [ ] **Step 6: Run tests and commit**
Run:
```bash
pnpm --filter web test -- role-permission-migration
pnpm --filter web type-check
pnpm --filter server test -- data.service
```
Expected: all commands PASS.
Commit:
```bash
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` (minimal `XiaobaoRiskSnapshot` type stub; Task 3 expands it)
- Create: `apps/web/lib/xiaobao-risk-evidence.ts` (minimal `VersionDailyEvidence` type stub; Task 3 expands it)
- [ ] **Step 1: Write failing risk engine tests**
Create `apps/web/lib/xiaobao-risk.test.ts`:
```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:
```bash
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:
```ts
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:
```ts
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`:
```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:
```bash
pnpm --filter web test -- xiaobao-risk
pnpm --filter web type-check
```
Expected: PASS.
Commit:
```bash
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`:
```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`:
```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:
```bash
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:
```ts
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:
```ts
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`:
```ts
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
```
Replace the trend calculation in `calcXiaobaoVersionRisk` after `confidence` is calculated:
```ts
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:
```bash
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:
```bash
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`:
```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:
```bash
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`:
```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`:
```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:
```ts
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
```
Add the optional field to `XiaobaoVersionRisk`:
```ts
aiInsight?: XiaobaoRiskInsight;
```
- [ ] **Step 5: Run tests and commit**
Run:
```bash
pnpm --filter web test -- xiaobao-risk-cache
pnpm --filter web type-check
```
Expected: PASS.
Commit:
```bash
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:
```ts
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`:
```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:
```bash
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`:
```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`:
```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:
```ts
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`:
```ts
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`:
```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:
```bash
pnpm --filter server test -- ai.service
pnpm --filter server type-check
pnpm --filter web type-check
```
Expected: PASS.
Commit:
```bash
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`:
```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:
```bash
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`:
```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:
```bash
pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check
```
Expected: PASS.
Commit:
```bash
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:
```ts
import { AlertTriangle, Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
```
Add item before `与我相关`:
```ts
{ 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`:
```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`:
```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`:
```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:
```bash
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:
```bash
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:
```ts
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { buildRiskInsightSignature, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
```
Inside `XiaobaoWarningContent`:
```ts
const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
useEffect(() => { fetchRiskData(); }, [fetchRiskData]);
```
Pass snapshots into risk calculation:
```ts
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id),
```
Add effect after `risks`:
```ts
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:
```ts
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:
```tsx
{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:
```tsx
{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:
```tsx
<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:
```bash
pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check
```
Expected: PASS.
Commit:
```bash
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 层”:
```md
## 小宝预警层
小宝预警是版本级风险预测能力,位于主导航“工作区 / 小宝预警”。它不替代版本健康度,而是在版本维度回答“能不能按期发版、为什么有风险、预计延期到多久、风险趋势如何”。
核心规则位于前端纯函数引擎:
- `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`:
```md
## 33. 小宝预警规则优先AI 只解释
**问题**:如果直接让 AI 判断版本能否发版模型可能忽略系统内的任务、Bug、日报和权限事实导致不可追溯的结论。
**决策**:小宝预警先由确定性规则计算 `riskScore``riskLevel``forecastReleaseDate``confidence`、趋势和静默风险。AI 只在规则触发后解释这些事实,不直接修改任何业务数据。
**理由**规则结果可测试、可追溯AI 文案可提升可读性但不能替代系统判断。
```
- [ ] **Step 3: Update workflow**
Add to `docs/workflow.md`:
```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:
```md
### V3.2 — 小宝预警 / Risk Watch Agent
小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版不做后台定时 Agent风险快照在用户打开页面时写入。
```
In `docs/agent-spec.md`, replace “Agent 2Risk Watch Agent风险预警— 待规划” with:
```md
### Agent 2Risk 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:
```powershell
$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:
```bash
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:
```bash
pnpm --filter web test
pnpm --filter web type-check
```
Expected: both commands PASS.
- [ ] **Step 2: Run server verification**
Run:
```bash
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:
```bash
curl http://localhost:3000/xiaobao-warning
```
Expected: HTTP 200 with HTML content.
- [ ] **Step 4: Inspect git status**
Run:
```bash
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:
```bash
git add <files fixed during verification>
git commit -m "fix(小宝预警): 修复验证问题"
```
Expected: commit succeeds, or no commit is needed because no fixes were required.