# 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 { 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 { 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 { 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 = { 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; requirementVersionMap: Map; } 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; 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; saveSnapshot: (snapshot: XiaobaoRiskSnapshot) => Promise; saveInsight: (insight: XiaobaoRiskInsightCacheItem) => Promise; } export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], insights: [], fetchRiskData: async () => { const [snapshots, insights] = await Promise.all([ loadServerData('xiaobao-risk-snapshots'), loadServerData('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 { 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 { 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; type RiskInsightPrevious = Pick; 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 { 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 = { on_track: '可按期', attention: '关注', at_risk: '高风险', likely_delayed: '预计延期', blocked: '阻塞', }; const LEVEL_STYLE: Record = { on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200', attention: 'bg-amber-50 text-amber-700 border-amber-200', at_risk: 'bg-orange-50 text-orange-700 border-orange-200', likely_delayed: 'bg-red-50 text-red-700 border-red-200', blocked: 'bg-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 ( ); } function Metric({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } ``` - [ ] **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 (
e.stopPropagation()}>

{risk.versionName}

小宝预警详情

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

{reason.label}

{reason.detail}

))}
{[...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].length === 0 ? : [...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].map((item) => (
{item.summary}
))}
{risk.silentRisks.length === 0 ? : risk.silentRisks.map((item) => (
{item.detail}
))}
{risk.trend.summary}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Empty({ text }: { text: string }) { return
{text}
; } ``` - [ ] **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 ( ); } 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(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(); 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 (

小宝预警

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

{risks.length} 个版本
{risks.length === 0 ? (
暂无小宝预警
) : (
{risks.map((risk) => setSelected(risk)} />)}
)}
{selected && ( setSelected(null)} onNavigate={() => router.push(`/versions/${selected.versionId}`)} /> )}
); } ``` - [ ] **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) => setSelected(risk)} />)} {selectedWithInsight && ( 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 &&

{risk.aiInsight.summary}

} ``` Modify `XiaobaoWarningDrawer` before the navigation button: ```tsx
{risk.aiInsight ? (

{risk.aiInsight.summary}

{risk.aiInsight.forecast}

{risk.aiInsight.suggestedActions.map((action) => (
{action}
))}
) : ( )}
``` - [ ] **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 2:Risk Watch Agent(风险预警)— 待规划” with: ```md ### 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: ```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 git commit -m "fix(小宝预警): 修复验证问题" ``` Expected: commit succeeds, or no commit is needed because no fixes were required.