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

86 KiB
Raw Blame History

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:

import assert from 'node:assert/strict';
import test from 'node:test';
import type { RoleItem } from './members';
import { mergePresetRolePermissions } from './role-permission-migration';

test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
  const roles: RoleItem[] = [
    {
      id: 'role-pm',
      name: '产品经理',
      createdAt: '2024-01-01',
      permissions: ['product:view'],
    },
  ];

  const result = mergePresetRolePermissions(roles);

  assert.equal(result.changed, true);
  assert.ok(result.roles[0].permissions.includes('product:view'));
  assert.ok(result.roles[0].permissions.includes('xiaobao.warning:view'));
  assert.ok(result.roles[0].permissions.includes('xiaobao.warning:manage'));
});

test('mergePresetRolePermissions keeps custom role permissions unchanged', () => {
  const roles: RoleItem[] = [
    {
      id: 'role-custom',
      name: '自定义角色',
      createdAt: '2024-01-01',
      permissions: ['version:view'],
    },
  ];

  const result = mergePresetRolePermissions(roles);

  assert.equal(result.changed, false);
  assert.deepEqual(result.roles[0].permissions, ['version:view']);
});
  • Step 2: Run the permission migration test to verify it fails

Run:

pnpm --filter web test -- role-permission-migration

Expected: FAIL because apps/web/lib/role-permission-migration.ts does not exist.

  • Step 3: Implement permission constants and migration helper

Modify apps/web/lib/permissions.ts by adding Xiaobao group before member/role groups:

  {
    module: 'xiaobao.warning',
    moduleLabel: '小宝预警',
    category: 'main',
    actions: [
      { action: 'view', label: '查看', permission: 'xiaobao.warning:view' },
      { action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' },
    ],
  },

Add defaults in DEFAULT_ROLE_PERMISSIONS:

  'role-pm': [
    ...std4('product').map((a) => a.permission),
    ...std4('project').map((a) => a.permission),
    ...std4('version').map((a) => a.permission),
    ...std4('requirement').map((a) => a.permission),
    'xiaobao.warning:view',
    'xiaobao.warning:manage',
    'version.req:view', 'version.req:manage',
    'version.product_plan:view', 'version.product_plan:manage',
    'overtime:view', 'member:view', 'role:view',
    'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
    'version.testcase:view', 'version.bug:view',
  ],
  'role-dev': [
    ...VIEW_ONLY_BASE,
    'xiaobao.warning:view',
    'version.devtask:view', 'version.devtask:manage',
    'version.bug:view', 'version.bug:edit',
    'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
    'overtime:view', 'overtime:create',
  ],
  'role-test': [
    ...VIEW_ONLY_BASE,
    'xiaobao.warning:view',
    'version.testcase:view', 'version.testcase:manage',
    'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
    'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
    'overtime:view', 'overtime:create',
  ],
  'role-design': [
    ...VIEW_ONLY_BASE,
    'xiaobao.warning:view',
    'version.ui_plan:view', 'version.ui_plan:manage',
    'version.research:view', 'version.product_plan:view', 'version.devtask:view',
    'version.testcase:view', 'version.bug:view',
    'overtime:view', 'overtime:create',
  ],

Create apps/web/lib/role-permission-migration.ts:

import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';

export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
  let changed = false;
  const next = roles.map((role) => {
    const defaults = DEFAULT_ROLE_PERMISSIONS[role.id];
    if (!defaults || role.permissions.includes('*')) return role;
    const merged = Array.from(new Set([...role.permissions, ...defaults]));
    if (merged.length === role.permissions.length) return role;
    changed = true;
    return { ...role, permissions: merged };
  });
  return { roles: next, changed };
}
  • Step 4: Wire migration into member store

Modify apps/web/stores/useMemberStore.ts imports:

import { mergePresetRolePermissions } from '@/lib/role-permission-migration';

Inside fetchMembers, after migratedRoles is built and before system admin role protection, add:

      const permissionMerged = mergePresetRolePermissions(migratedRoles);
      const rolesWithNewDefaults = permissionMerged.roles;
      const adminRole = PRESET_ROLES.find((r) => r.id === SYSTEM_ADMIN_ROLE_ID)!;
      const roles = rolesWithNewDefaults.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
        ? rolesWithNewDefaults.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
        : [adminRole, ...rolesWithNewDefaults];
      const rolesChanged =
        permissionMerged.changed ||
        roles.length !== rolesWithNewDefaults.length ||
        roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));

Remove the old local adminRole, roles, and rolesChanged declarations to avoid duplicate identifiers.

  • Step 5: Add AppData keys

Modify apps/web/lib/server-data.ts:

  | 'work-activities'
  | 'xiaobao-risk-insights'
  | 'xiaobao-risk-snapshots'
  | 'overtime';

Modify apps/server/src/modules/data/data-keys.ts:

  'work-activities',
  'xiaobao-risk-insights',
  'xiaobao-risk-snapshots',
  'overtime',
  • Step 6: Run tests and commit

Run:

pnpm --filter web test -- role-permission-migration
pnpm --filter web type-check
pnpm --filter server test -- data.service

Expected: all commands PASS.

Commit:

git add apps/web/lib/permissions.ts apps/web/lib/role-permission-migration.ts apps/web/lib/role-permission-migration.test.ts apps/web/stores/useMemberStore.ts apps/web/lib/server-data.ts apps/server/src/modules/data/data-keys.ts
git commit -m "feat(小宝预警): 增加权限与数据键"

Task 2: Core Risk Forecast Engine

Files:

  • Create: apps/web/lib/xiaobao-risk.ts

  • Create: apps/web/lib/xiaobao-risk.test.ts

  • Create: apps/web/lib/xiaobao-risk-trend.ts (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:

import assert from 'node:assert/strict';
import test from 'node:test';
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';

function version(patch: any = {}) {
  return {
    id: 'ver-1',
    name: 'V1.0',
    status: 'developing',
    productName: 'FTB',
    projectName: '项目管理',
    expectedReleaseDate: '2026-07-03T10:00:00.000Z',
    members: [{ name: '张三', role: 'frontend' }],
    ...patch,
  };
}

function devTask(patch: Partial<DevTask> = {}): DevTask {
  return {
    id: 'dev-1',
    taskNo: 'DEV-001',
    requirementId: 'req-1',
    title: '开发任务',
    categoryId: 'cat-frontend',
    assigneeId: '张三',
    priority: 'P1',
    expectedStartAt: '2026-07-01T01:00:00.000Z',
    expectedEndAt: '2026-07-01T09:00:00.000Z',
    estimateHours: 8,
    status: 'todo',
    isBlocked: false,
    createdBy: '张三',
    createdAt: '2026-07-01T01:00:00.000Z',
    updatedAt: '2026-07-01T01:00:00.000Z',
    ...patch,
  };
}

function testCase(patch: Partial<TestCase> = {}): TestCase {
  return {
    id: 'tc-1',
    caseNo: 'TC-001',
    versionId: 'ver-1',
    title: '测试用例',
    categoryId: 'cat-test',
    priority: 'P1',
    status: 'pending',
    estimateHours: 4,
    createdBy: '李四',
    createdAt: '2026-07-01T01:00:00.000Z',
    updatedAt: '2026-07-01T01:00:00.000Z',
    ...patch,
  };
}

function bug(patch: Partial<Bug> = {}): Bug {
  return {
    id: 'bug-1',
    bugNo: 'BUG-001',
    versionId: 'ver-1',
    testCaseId: 'tc-1',
    title: 'P1 Bug',
    description: '阻断发版',
    severity: 'major',
    priority: 'P1',
    reportedBy: '李四',
    assigneeId: '张三',
    status: 'open',
    estimateHours: 4,
    createdAt: '2026-07-02T01:00:00.000Z',
    updatedAt: '2026-07-02T01:00:00.000Z',
    ...patch,
  };
}

test('calcXiaobaoVersionRisk predicts delay when remaining work exceeds release date', () => {
  const risk = calcXiaobaoVersionRisk({
    version: version(),
    plans: [],
    requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
    devTasks: [devTask({ estimateHours: 40 })],
    testCases: [testCase({ estimateHours: 16 })],
    bugs: [],
    dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
    snapshots: [],
    now: new Date('2026-07-02T01:00:00.000Z'),
  });

  assert.equal(risk.riskLevel, 'likely_delayed');
  assert.ok(risk.riskScore >= 70);
  assert.ok(risk.delayDays > 0);
  assert.ok(risk.reasons.some((r) => r.key === 'remaining_work'));
});

test('calcXiaobaoVersionRisk marks blocked when critical signals exist', () => {
  const risk = calcXiaobaoVersionRisk({
    version: version(),
    plans: [],
    requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
    devTasks: [devTask({ isBlocked: true, blockReason: '接口未联调' })],
    testCases: [testCase({ status: 'failed' })],
    bugs: [bug({ severity: 'critical' })],
    dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
    snapshots: [],
    now: new Date('2026-07-02T01:00:00.000Z'),
  });

  assert.equal(risk.riskLevel, 'blocked');
  assert.ok(risk.reasons.some((r) => r.key === 'critical_bug'));
  assert.ok(risk.reasons.some((r) => r.key === 'blocked_work'));
});
  • Step 2: Run the risk engine test to verify it fails

Run:

pnpm --filter web test -- xiaobao-risk

Expected: FAIL because apps/web/lib/xiaobao-risk.ts does not exist.

  • Step 3: Implement the core risk engine

Create apps/web/lib/xiaobao-risk-trend.ts as a type stub:

import type { XiaobaoRiskLevel } from './xiaobao-risk';

export interface XiaobaoRiskSnapshot {
  versionId: string;
  date: string;
  riskScore: number;
  riskLevel: XiaobaoRiskLevel;
  forecastReleaseDate?: string;
  openBugCount: number;
  failedTestCount: number;
  blockedCount: number;
  silentRiskCount: number;
  confidence: number;
  createdAt: string;
}

Create apps/web/lib/xiaobao-risk-evidence.ts as a type stub:

import type { SilentRisk } from './xiaobao-risk';

export interface EvidenceItem {
  id: string;
  title: string;
  summary: string;
  occurredAt: string;
  actorId?: string;
}

export interface VersionDailyEvidence {
  todayDeliveries: EvidenceItem[];
  todayProgress: EvidenceItem[];
  todayRisks: EvidenceItem[];
  progressNotes: EvidenceItem[];
  needsProgressItems: EvidenceItem[];
  recentActivityCount: number;
  lastActivityAt?: string;
  silentRisks?: SilentRisk[];
}

Create apps/web/lib/xiaobao-risk.ts:

import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
import type { TestCase } from './test-case';
import { getTestCaseEstimateHours } from './test-case';
import type { Bug } from './bug';
import { addWorkHours } from './work-hours';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';

export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
export type XiaobaoConfidenceLevel = 'low' | 'medium' | 'high';

export interface XiaobaoVersionRef {
  id: string;
  name: string;
  status?: string;
  productName?: string;
  projectName?: string;
  expectedReleaseDate?: string | null;
  members?: Array<{ name: string; role: string }>;
}

export interface RiskReason {
  key: string;
  label: string;
  severity: 'low' | 'medium' | 'high' | 'critical';
  detail: string;
}

export interface SilentRisk {
  key: 'no_update' | 'no_report' | 'no_activity' | 'unhandled';
  days: number;
  detail: string;
}

export interface XiaobaoRiskSignals {
  unfinishedCount: number;
  openBugCount: number;
  criticalBugCount: number;
  failedTestCount: number;
  blockedCount: number;
  silentRiskCount: number;
  daysToExpectedRelease?: number;
}

export interface XiaobaoVersionRisk {
  versionId: string;
  versionName: string;
  productName?: string;
  projectName?: string;
  riskScore: number;
  riskLevel: XiaobaoRiskLevel;
  expectedReleaseDate?: string;
  forecastReleaseDate?: string;
  delayDays: number;
  confidence: number;
  confidenceLevel: XiaobaoConfidenceLevel;
  reasons: RiskReason[];
  silentRisks: SilentRisk[];
  signals: XiaobaoRiskSignals;
  dailyEvidence: VersionDailyEvidence;
  trend: {
    direction: 'up' | 'down' | 'stable' | 'unknown';
    delta: number;
    summary: string;
  };
}

export interface CalcXiaobaoVersionRiskInput {
  version: XiaobaoVersionRef;
  plans: VersionPlan[];
  requirements: Array<{ id: string; versionId?: string | null }>;
  devTasks: DevTask[];
  testCases: TestCase[];
  bugs: Bug[];
  dailyEvidence: VersionDailyEvidence;
  snapshots: XiaobaoRiskSnapshot[];
  now?: Date;
}

const BUG_FALLBACK_HOURS: Record<Bug['severity'], number> = {
  critical: 8,
  major: 4,
  minor: 2,
  trivial: 1,
};

export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): XiaobaoVersionRisk {
  const now = input.now ?? new Date();
  const reasons: RiskReason[] = [];
  const silentRisks = input.dailyEvidence.silentRisks ?? [];
  const remainingHours = calcRemainingHours(input.devTasks, input.testCases, input.bugs);
  const forecastReleaseDate = remainingHours > 0 ? addWorkHours(now.toISOString(), remainingHours) : now.toISOString();
  const expected = input.version.expectedReleaseDate || undefined;
  const delayDays = expected ? calcDelayDays(forecastReleaseDate, expected) : 0;

  const openBugs = input.bugs.filter((bug) => bug.status !== 'closed' && bug.status !== 'rejected');
  const criticalBugs = openBugs.filter((bug) => bug.severity === 'critical' || bug.priority === 'P0' || bug.priority === 'P1');
  const blockedDevTasks = input.devTasks.filter((task) => task.isBlocked);
  const failedOrBlockedTests = input.testCases.filter((testCase) => testCase.status === 'failed' || testCase.status === 'blocked');
  const unfinishedDevTasks = input.devTasks.filter((task) => task.status !== 'submitted');
  const unfinishedTestCases = input.testCases.filter((testCase) => testCase.status !== 'passed');
  const signals: XiaobaoRiskSignals = {
    unfinishedCount: unfinishedDevTasks.length + unfinishedTestCases.length + openBugs.length,
    openBugCount: openBugs.length,
    criticalBugCount: criticalBugs.length,
    failedTestCount: failedOrBlockedTests.length,
    blockedCount: blockedDevTasks.length + failedOrBlockedTests.length,
    silentRiskCount: silentRisks.length,
    daysToExpectedRelease: expected ? calcDaysUntil(expected, now) : undefined,
  };

  if (remainingHours > 0) {
    reasons.push({ key: 'remaining_work', label: '剩余工作量', severity: delayDays > 0 ? 'high' : 'medium', detail: `剩余约 ${roundTenth(remainingHours)}h` });
  }
  if (criticalBugs.length > 0) {
    reasons.push({ key: 'critical_bug', label: '关键 Bug 未关闭', severity: 'critical', detail: `${criticalBugs.length} 个 P1/P0 或致命 Bug 未关闭` });
  }
  if (blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0) {
    reasons.push({ key: 'blocked_work', label: '阻塞项', severity: 'critical', detail: `${blockedDevTasks.length + failedOrBlockedTests.length} 个开发/测试阻塞或失败项` });
  }
  if (silentRisks.length > 0) {
    reasons.push({ key: 'silent_risk', label: '静默风险', severity: 'medium', detail: `${silentRisks.length} 个静默风险` });
  }

  const riskScore = clampRiskScore(
    15 +
    Math.min(35, remainingHours * 1.2) +
    Math.max(0, delayDays) * 8 +
    criticalBugs.length * 18 +
    (blockedDevTasks.length + failedOrBlockedTests.length) * 12 +
    silentRisks.length * 8,
  );
  const riskLevel = calcRiskLevel(riskScore, {
    hasCriticalBug: criticalBugs.length > 0,
    hasBlocking: blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0,
    delayDays,
  });
  const confidence = calcConfidence(input, remainingHours);
  const trend = calcTrend(input.snapshots, riskScore);

  return {
    versionId: input.version.id,
    versionName: input.version.name,
    productName: input.version.productName,
    projectName: input.version.projectName,
    riskScore,
    riskLevel,
    expectedReleaseDate: expected,
    forecastReleaseDate,
    delayDays,
    confidence,
    confidenceLevel: confidence >= 75 ? 'high' : confidence >= 45 ? 'medium' : 'low',
    reasons,
    silentRisks,
    signals,
    dailyEvidence: input.dailyEvidence,
    trend,
  };
}

function calcRemainingHours(devTasks: DevTask[], testCases: TestCase[], bugs: Bug[]): number {
  const devHours = devTasks.reduce((sum, task) => sum + getEstimateHours(task) * ((100 - STATUS_PROGRESS[task.status]) / 100), 0);
  const testHours = testCases.reduce((sum, testCase) => {
    if (testCase.status === 'passed') return sum;
    return sum + getTestCaseEstimateHours(testCase);
  }, 0);
  const bugHours = bugs.reduce((sum, bug) => {
    if (bug.status === 'closed' || bug.status === 'rejected') return sum;
    return sum + (bug.estimateHours ?? bug.aiEstimateHours ?? BUG_FALLBACK_HOURS[bug.severity]);
  }, 0);
  return roundTenth(devHours + testHours + bugHours);
}

function calcDelayDays(forecastIso: string, expectedIso: string): number {
  const forecast = new Date(forecastIso);
  const expected = new Date(expectedIso);
  forecast.setHours(0, 0, 0, 0);
  expected.setHours(0, 0, 0, 0);
  return Math.max(0, Math.ceil((forecast.getTime() - expected.getTime()) / 86400000));
}

function calcDaysUntil(targetIso: string, now: Date): number {
  const target = new Date(targetIso);
  const current = new Date(now);
  target.setHours(0, 0, 0, 0);
  current.setHours(0, 0, 0, 0);
  return Math.ceil((target.getTime() - current.getTime()) / 86400000);
}

function calcRiskLevel(score: number, flags: { hasCriticalBug: boolean; hasBlocking: boolean; delayDays: number }): XiaobaoRiskLevel {
  if (flags.hasCriticalBug || flags.hasBlocking) return 'blocked';
  if (flags.delayDays > 0 || score >= 75) return 'likely_delayed';
  if (score >= 55) return 'at_risk';
  if (score >= 30) return 'attention';
  return 'on_track';
}

function calcConfidence(input: CalcXiaobaoVersionRiskInput, remainingHours: number): number {
  let confidence = 100;
  if (!input.version.expectedReleaseDate) confidence -= 30;
  if (remainingHours === 0 && input.devTasks.some((task) => !task.estimateHours && !task.aiEstimateHours)) confidence -= 15;
  if (input.testCases.length === 0) confidence -= 15;
  if (!input.version.members || input.version.members.length === 0) confidence -= 10;
  if (input.dailyEvidence.recentActivityCount === 0) confidence -= 10;
  if (input.snapshots.length < 2) confidence -= 10;
  return Math.max(0, Math.min(100, Math.round(confidence)));
}

function calcTrend(snapshots: XiaobaoRiskSnapshot[], riskScore: number): XiaobaoVersionRisk['trend'] {
  const last = snapshots.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
  if (!last) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
  const delta = riskScore - last.riskScore;
  if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
  if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
  return { direction: 'stable', delta, summary: '风险基本稳定' };
}

function clampRiskScore(value: number): number {
  return Math.max(0, Math.min(100, Math.round(value)));
}

function roundTenth(value: number): number {
  return Math.round(value * 10) / 10;
}
  • Step 4: Run tests and commit

Run:

pnpm --filter web test -- xiaobao-risk
pnpm --filter web type-check

Expected: PASS.

Commit:

git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-evidence.ts
git commit -m "feat(小宝预警): 增加版本风险预测引擎"

Task 3: Diary Evidence, Silent Risk, Trend, And Signatures

Files:

  • Modify: apps/web/lib/xiaobao-risk-evidence.ts

  • Create: apps/web/lib/xiaobao-risk-evidence.test.ts

  • Modify: apps/web/lib/xiaobao-risk-trend.ts

  • Create: apps/web/lib/xiaobao-risk-trend.test.ts

  • Modify: apps/web/lib/xiaobao-risk.ts

  • Step 1: Write failing evidence and trend tests

Create apps/web/lib/xiaobao-risk-evidence.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence';

test('buildVersionDailyEvidence groups version activity and detects no activity', () => {
  const evidence = buildVersionDailyEvidence({
    versionId: 'ver-1',
    workItems: [{ id: 'dev-1', versionId: 'ver-1', title: '开发', status: 'in_progress', completed: false, type: 'devTask', productName: 'FTB', projectName: '项目', versionName: 'V1', raw: { updatedAt: '2026-06-24T01:00:00.000Z' } } as any],
    activities: [],
    worklogs: [],
    now: new Date('2026-06-29T01:00:00.000Z'),
  });

  assert.equal(evidence.recentActivityCount, 0);
  assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_activity'));
  assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_update'));
});

test('buildXiaobaoWorkItems keeps all version work instead of filtering by current user', () => {
  const items = buildXiaobaoWorkItems({
    plans: [{ id: 'plan-1', versionId: 'ver-1', type: 'product', title: 'Product plan', status: 'in_progress', owner: 'pm' } as any],
    devTasks: [{ id: 'dev-1', requirementId: 'req-1', title: 'Develop feature', status: 'todo', priority: 'P1' } as any],
    testCases: [{ id: 'tc-1', versionId: 'ver-1', title: 'Run test', status: 'pending', priority: 'P1' } as any],
    bugs: [{ id: 'bug-1', versionId: 'ver-1', title: 'Fix bug', status: 'open', priority: 'P1', severity: 'major' } as any],
    versionMap: new Map([['ver-1', { id: 'ver-1', name: 'V1', productName: 'FTB', projectName: 'PM' }]]),
    requirementVersionMap: new Map([['req-1', 'ver-1']]),
  });

  assert.deepEqual(items.map((item) => item.id).sort(), ['bug-1', 'dev-1', 'plan-1', 'tc-1']);
});

Create apps/web/lib/xiaobao-risk-trend.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';

test('summarizeRiskTrend detects continuous rising risk', () => {
  const trend = summarizeRiskTrend([
    { versionId: 'ver-1', date: '2026-06-27', riskScore: 38, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-27T01:00:00.000Z' },
    { versionId: 'ver-1', date: '2026-06-28', riskScore: 52, riskLevel: 'attention', openBugCount: 1, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 75, createdAt: '2026-06-28T01:00:00.000Z' },
    { versionId: 'ver-1', date: '2026-06-29', riskScore: 71, riskLevel: 'at_risk', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 70, createdAt: '2026-06-29T01:00:00.000Z' },
  ]);

  assert.equal(trend.direction, 'up');
  assert.equal(trend.summary, '风险连续 3 次上升');
});

test('buildRiskSignature changes when score and bug counts change', () => {
  const a = buildRiskSignature({ versionId: 'ver-1', riskScore: 35, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
  const b = buildRiskSignature({ versionId: 'ver-1', riskScore: 68, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
  assert.notEqual(a, b);
});
  • Step 2: Run tests to verify they fail

Run:

pnpm --filter web test -- xiaobao-risk-evidence
pnpm --filter web test -- xiaobao-risk-trend

Expected: FAIL because buildVersionDailyEvidence, buildXiaobaoWorkItems, summarizeRiskTrend, and buildRiskSignature are not implemented yet.

  • Step 3: Implement evidence aggregation

Replace the type stub in apps/web/lib/xiaobao-risk-evidence.ts with:

import type { TaskWorklog } from './task-worklog';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';
import type { WorkActivity } from './work-activity';
import type { WorkItem } from './workspace-engine';
import type { SilentRisk } from './xiaobao-risk';

export interface EvidenceItem {
  id: string;
  title: string;
  summary: string;
  occurredAt: string;
  actorId?: string;
}

export interface VersionDailyEvidence {
  todayDeliveries: EvidenceItem[];
  todayProgress: EvidenceItem[];
  todayRisks: EvidenceItem[];
  progressNotes: EvidenceItem[];
  needsProgressItems: EvidenceItem[];
  recentActivityCount: number;
  lastActivityAt?: string;
  silentRisks: SilentRisk[];
}

export interface BuildVersionDailyEvidenceInput {
  versionId: string;
  workItems: WorkItem[];
  activities: WorkActivity[];
  worklogs: TaskWorklog[];
  now?: Date;
}

export interface XiaobaoVersionContext {
  id: string;
  name: string;
  productName: string;
  projectName: string;
}

export interface BuildXiaobaoWorkItemsInput {
  plans: VersionPlan[];
  devTasks: DevTask[];
  testCases: TestCase[];
  bugs: Bug[];
  versionMap: Map<string, XiaobaoVersionContext>;
  requirementVersionMap: Map<string, string>;
}

const MS_PER_DAY = 86400000;

export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
  const items: WorkItem[] = [];

  input.plans.forEach((plan) => {
    const version = input.versionMap.get(plan.versionId);
    items.push({
      id: plan.id,
      type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
      title: plan.title,
      status: plan.status,
      completed: plan.status === 'completed',
      productName: version?.productName ?? '-',
      projectName: version?.projectName ?? '-',
      versionName: version?.name ?? '-',
      versionId: plan.versionId,
      extra: { startTime: plan.startTime, endTime: plan.endTime },
      raw: plan,
    });
  });

  input.devTasks.forEach((task) => {
    const versionId = input.requirementVersionMap.get(task.requirementId) ?? '';
    const version = input.versionMap.get(versionId);
    items.push({
      id: task.id,
      type: 'devTask',
      title: task.title,
      status: task.status,
      completed: task.status === 'submitted',
      productName: version?.productName ?? '-',
      projectName: version?.projectName ?? '-',
      versionName: version?.name ?? '-',
      versionId,
      priority: task.priority,
      extra: { taskNo: task.taskNo, expectedStartAt: task.expectedStartAt, expectedEndAt: task.expectedEndAt },
      raw: task,
    });
  });

  input.testCases.forEach((testCase) => {
    const version = input.versionMap.get(testCase.versionId);
    items.push({
      id: testCase.id,
      type: 'testCase',
      title: testCase.title,
      status: testCase.status,
      completed: testCase.status === 'passed',
      productName: version?.productName ?? '-',
      projectName: version?.projectName ?? '-',
      versionName: version?.name ?? '-',
      versionId: testCase.versionId,
      priority: testCase.priority,
      extra: { caseNo: testCase.caseNo },
      raw: testCase,
    });
  });

  input.bugs.forEach((bug) => {
    const version = input.versionMap.get(bug.versionId);
    items.push({
      id: bug.id,
      type: 'bug',
      title: bug.title,
      status: bug.status,
      completed: bug.status === 'closed' || bug.status === 'rejected',
      productName: version?.productName ?? '-',
      projectName: version?.projectName ?? '-',
      versionName: version?.name ?? '-',
      versionId: bug.versionId,
      priority: bug.priority,
      extra: { bugNo: bug.bugNo, severity: bug.severity },
      raw: bug,
    });
  });

  return items;
}

export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
  const now = input.now ?? new Date();
  const today = toDateKey(now);
  const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
  const sourceIds = new Set(versionItems.map((item) => item.id));
  const activities = input.activities.filter((activity) => sourceIds.has(activity.sourceId));
  const worklogs = input.worklogs.filter((worklog) => sourceIds.has(worklog.taskId));
  const recentActivities = activities.filter((activity) => daysBetween(activity.occurredAt, now) <= 3);
  const todayActivities = activities.filter((activity) => activity.date === today);

  const evidence: VersionDailyEvidence = {
    todayDeliveries: toEvidence(todayActivities.filter((activity) => activity.category === 'delivery')),
    todayProgress: toEvidence(todayActivities.filter((activity) => activity.category === 'progress')),
    todayRisks: toEvidence(todayActivities.filter((activity) => activity.category === 'risk')),
    progressNotes: [
      ...toEvidence(todayActivities.filter((activity) => activity.category === 'note')),
      ...worklogs.filter((worklog) => worklog.date === today).map((worklog) => ({
        id: worklog.id,
        title: '进展说明',
        summary: worklog.workContent,
        occurredAt: worklog.createdAt,
        actorId: worklog.userId,
      })),
    ],
    needsProgressItems: [],
    recentActivityCount: recentActivities.length,
    lastActivityAt: activities.map((activity) => activity.occurredAt).sort().reverse()[0],
    silentRisks: [],
  };

  evidence.needsProgressItems = versionItems
    .filter((item) => !item.completed && ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(item.status))
    .filter((item) => daysBetween(getUpdatedAt(item), now) >= 3)
    .map((item) => ({ id: item.id, title: item.title, summary: `已 ${daysBetween(getUpdatedAt(item), now)} 天无人处理`, occurredAt: getUpdatedAt(item) }));

  const noActivityDays = evidence.lastActivityAt ? daysBetween(evidence.lastActivityAt, now) : 999;
  const lastWorklogAt = worklogs.map((worklog) => worklog.createdAt).sort().reverse()[0];
  const noReportDays = lastWorklogAt ? daysBetween(lastWorklogAt, now) : 999;
  const maxNoUpdateDays = Math.max(0, ...versionItems.map((item) => daysBetween(getUpdatedAt(item), now)));

  if (maxNoUpdateDays >= 5) evidence.silentRisks.push({ key: 'no_update', days: maxNoUpdateDays, detail: `${maxNoUpdateDays} 天无更新` });
  if (noReportDays >= 8) evidence.silentRisks.push({ key: 'no_report', days: noReportDays, detail: `${noReportDays} 天无日报` });
  if (noActivityDays >= 4) evidence.silentRisks.push({ key: 'no_activity', days: noActivityDays, detail: `${noActivityDays} 天无活动` });
  if (evidence.needsProgressItems.length > 0) evidence.silentRisks.push({ key: 'unhandled', days: 3, detail: `${evidence.needsProgressItems.length} 项超过 3 天无人处理` });

  return evidence;
}

function toEvidence(activities: WorkActivity[]): EvidenceItem[] {
  return activities.map((activity) => ({
    id: activity.id,
    title: activity.title,
    summary: activity.summary,
    occurredAt: activity.occurredAt,
    actorId: activity.actorId,
  }));
}

function getUpdatedAt(item: WorkItem): string {
  const raw = item.raw as Record<string, unknown>;
  return typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date(0).toISOString();
}

function daysBetween(iso: string | undefined, now: Date): number {
  if (!iso) return 999;
  const value = new Date(iso).getTime();
  if (!Number.isFinite(value)) return 999;
  return Math.floor((now.getTime() - value) / MS_PER_DAY);
}

function toDateKey(date: Date): string {
  const pad = (n: number) => String(n).padStart(2, '0');
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
  • Step 4: Implement trend and signatures

Replace the type stub in apps/web/lib/xiaobao-risk-trend.ts with:

import type { XiaobaoRiskLevel } from './xiaobao-risk';

export interface XiaobaoRiskSnapshot {
  versionId: string;
  date: string;
  riskScore: number;
  riskLevel: XiaobaoRiskLevel;
  forecastReleaseDate?: string;
  openBugCount: number;
  failedTestCount: number;
  blockedCount: number;
  silentRiskCount: number;
  confidence: number;
  createdAt: string;
}

export interface RiskTrendSummary {
  direction: 'up' | 'down' | 'stable' | 'unknown';
  delta: number;
  summary: string;
}

export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
  const ordered = snapshots.slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
  if (ordered.length < 2) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
  const last = ordered[ordered.length - 1];
  const first = ordered[Math.max(0, ordered.length - 3)];
  const delta = last.riskScore - first.riskScore;
  const recent = ordered.slice(-3);
  const rising = recent.length === 3 && recent[0].riskScore < recent[1].riskScore && recent[1].riskScore < recent[2].riskScore;
  const falling = recent.length === 3 && recent[0].riskScore > recent[1].riskScore && recent[1].riskScore > recent[2].riskScore;
  if (rising) return { direction: 'up', delta, summary: '风险连续 3 次上升' };
  if (falling) return { direction: 'down', delta, summary: '风险连续 3 次下降' };
  if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
  if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
  return { direction: 'stable', delta, summary: '风险基本稳定' };
}

export function summarizeRiskTrendWithCurrent(snapshots: XiaobaoRiskSnapshot[], current: XiaobaoRiskSnapshot): RiskTrendSummary {
  return summarizeRiskTrend([...snapshots, current]);
}

export function buildRiskSignature(input: {
  versionId: string;
  riskScore: number;
  riskLevel: XiaobaoRiskLevel;
  forecastReleaseDate?: string;
  openBugCount: number;
  failedTestCount: number;
  blockedCount: number;
  silentRiskCount: number;
  confidence: number;
}): string {
  return [
    input.versionId,
    input.riskScore,
    input.riskLevel,
    input.forecastReleaseDate ?? '',
    input.openBugCount,
    input.failedTestCount,
    input.blockedCount,
    input.silentRiskCount,
    input.confidence,
  ].join('|');
}
  • Step 5: Wire trend types into core engine

Modify apps/web/lib/xiaobao-risk.ts imports and replace the local trend summary logic with summarizeRiskTrendWithCurrent:

import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';

Replace the trend calculation in calcXiaobaoVersionRisk after confidence is calculated:

  const trend = summarizeRiskTrendWithCurrent(input.snapshots, {
    versionId: input.version.id,
    date: now.toISOString().slice(0, 10),
    riskScore,
    riskLevel,
    forecastReleaseDate,
    openBugCount: signals.criticalBugCount,
    failedTestCount: signals.failedTestCount,
    blockedCount: signals.blockedCount,
    silentRiskCount: signals.silentRiskCount,
    confidence,
    createdAt: now.toISOString(),
  });

Then delete the old local calcTrend helper from apps/web/lib/xiaobao-risk.ts.

  • Step 6: Run tests and commit

Run:

pnpm --filter web test -- xiaobao-risk-evidence
pnpm --filter web test -- xiaobao-risk-trend
pnpm --filter web test -- xiaobao-risk
pnpm --filter web type-check

Expected: PASS.

Commit:

git add apps/web/lib/xiaobao-risk-evidence.ts apps/web/lib/xiaobao-risk-evidence.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-trend.test.ts apps/web/lib/xiaobao-risk.ts
git commit -m "feat(小宝预警): 增加日报证据与风险趋势"

Task 4: Snapshots And Insight Cache Store

Files:

  • Create: apps/web/stores/useXiaobaoRiskStore.ts

  • Create: apps/web/lib/xiaobao-risk-cache.ts

  • Create: apps/web/lib/xiaobao-risk-cache.test.ts

  • Modify: apps/web/lib/xiaobao-risk.ts

  • Step 1: Write failing cache tests

Create apps/web/lib/xiaobao-risk-cache.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { findCachedInsight, upsertInsight, upsertDailySnapshot } from './xiaobao-risk-cache';

test('findCachedInsight returns matching signature only', () => {
  const rows = [{ versionId: 'ver-1', riskSignature: 'a', insight: { summary: '旧解读', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' }];
  assert.equal(findCachedInsight(rows, 'ver-1', 'a')?.insight.summary, '旧解读');
  assert.equal(findCachedInsight(rows, 'ver-1', 'b'), undefined);
});

test('upsertDailySnapshot keeps one snapshot per version and date', () => {
  const rows = upsertDailySnapshot([], { versionId: 'ver-1', date: '2026-06-29', riskScore: 50, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-29T01:00:00.000Z' });
  const next = upsertDailySnapshot(rows, { ...rows[0], riskScore: 70, createdAt: '2026-06-29T02:00:00.000Z' });
  assert.equal(next.length, 1);
  assert.equal(next[0].riskScore, 70);
});

test('upsertInsight replaces existing version signature pair', () => {
  const first = upsertInsight([], { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'A', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' });
  const second = upsertInsight(first, { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'B', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T02:00:00.000Z' }, generatedAt: '2026-06-29T02:00:00.000Z' });
  assert.equal(second.length, 1);
  assert.equal(second[0].insight.summary, 'B');
});
  • Step 2: Run cache tests to verify failure

Run:

pnpm --filter web test -- xiaobao-risk-cache

Expected: FAIL because apps/web/lib/xiaobao-risk-cache.ts does not exist.

  • Step 3: Implement cache helpers and store

Create apps/web/lib/xiaobao-risk-cache.ts:

import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';

export interface XiaobaoRiskInsight {
  summary: string;
  why: string[];
  forecast: string;
  recommendedReleaseWindow?: string;
  suggestedActions: string[];
  ownerHints: string[];
  generatedAt: string;
}

export interface XiaobaoRiskInsightCacheItem {
  versionId: string;
  riskSignature: string;
  insight: XiaobaoRiskInsight;
  generatedAt: string;
  providerInfo?: { providerId?: string; model?: string };
}

export function findCachedInsight(rows: XiaobaoRiskInsightCacheItem[], versionId: string, riskSignature: string): XiaobaoRiskInsightCacheItem | undefined {
  return rows.find((row) => row.versionId === versionId && row.riskSignature === riskSignature);
}

export function upsertInsight(rows: XiaobaoRiskInsightCacheItem[], item: XiaobaoRiskInsightCacheItem): XiaobaoRiskInsightCacheItem[] {
  return [item, ...rows.filter((row) => !(row.versionId === item.versionId && row.riskSignature === item.riskSignature))];
}

export function upsertDailySnapshot(rows: XiaobaoRiskSnapshot[], item: XiaobaoRiskSnapshot): XiaobaoRiskSnapshot[] {
  return [item, ...rows.filter((row) => !(row.versionId === item.versionId && row.date === item.date))];
}

Create apps/web/stores/useXiaobaoRiskStore.ts:

'use client';

import { create } from 'zustand';
import { loadServerData, saveServerData } from '@/lib/server-data';
import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import type { XiaobaoRiskInsightCacheItem } from '@/lib/xiaobao-risk-cache';
import { upsertDailySnapshot, upsertInsight } from '@/lib/xiaobao-risk-cache';

interface XiaobaoRiskState {
  snapshots: XiaobaoRiskSnapshot[];
  insights: XiaobaoRiskInsightCacheItem[];
  fetchRiskData: () => Promise<void>;
  saveSnapshot: (snapshot: XiaobaoRiskSnapshot) => Promise<void>;
  saveInsight: (insight: XiaobaoRiskInsightCacheItem) => Promise<void>;
}

export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
  snapshots: [],
  insights: [],
  fetchRiskData: async () => {
    const [snapshots, insights] = await Promise.all([
      loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots'),
      loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights'),
    ]);
    set({ snapshots: snapshots ?? [], insights: insights ?? [] });
  },
  saveSnapshot: async (snapshot) => {
    const snapshots = upsertDailySnapshot(get().snapshots, snapshot);
    set({ snapshots });
    await saveServerData('xiaobao-risk-snapshots', snapshots);
  },
  saveInsight: async (insight) => {
    const insights = upsertInsight(get().insights, insight);
    set({ insights });
    await saveServerData('xiaobao-risk-insights', insights);
  },
}));
  • Step 4: Expose optional AI insight on risk view model

Modify apps/web/lib/xiaobao-risk.ts imports:

import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';

Add the optional field to XiaobaoVersionRisk:

  aiInsight?: XiaobaoRiskInsight;
  • Step 5: Run tests and commit

Run:

pnpm --filter web test -- xiaobao-risk-cache
pnpm --filter web type-check

Expected: PASS.

Commit:

git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk-cache.ts apps/web/lib/xiaobao-risk-cache.test.ts apps/web/stores/useXiaobaoRiskStore.ts
git commit -m "feat(小宝预警): 增加趋势与解读缓存"

Task 5: Backend AI Risk Interpretation

Files:

  • Modify: packages/shared/src/agent.ts

  • Create: apps/server/src/modules/ai/dto/risk-interpret.dto.ts

  • Create: apps/server/src/modules/ai/prompts/risk-interpret.ts

  • Modify: apps/server/src/modules/ai/ai.service.ts

  • Modify: apps/server/src/modules/ai/ai.controller.ts

  • Modify: apps/server/src/modules/ai/ai.service.spec.ts

  • Step 1: Add shared request and response types

Modify packages/shared/src/agent.ts after decompose response types:

export interface AgentRiskReason {
  key: string;
  label: string;
  severity: 'low' | 'medium' | 'high' | 'critical';
  detail: string;
}

export interface AgentRiskInterpretRequest {
  versionId: string;
  versionName: string;
  productName?: string;
  projectName?: string;
  riskScore: number;
  riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
  expectedReleaseDate?: string;
  forecastReleaseDate?: string;
  delayDays: number;
  confidence: number;
  signals: {
    unfinishedCount: number;
    openBugCount: number;
    criticalBugCount: number;
    failedTestCount: number;
    blockedCount: number;
    silentRiskCount: number;
    daysToExpectedRelease?: number;
  };
  trendSummary: string;
  reasons: AgentRiskReason[];
  silentRisks: Array<{ key: string; days: number; detail: string }>;
  dailyEvidence: {
    todayDeliveries: string[];
    todayProgress: string[];
    todayRisks: string[];
    progressNotes: string[];
    needsProgressItems: string[];
    recentActivityCount: number;
    lastActivityAt?: string;
  };
}

export interface AgentRiskInsight {
  summary: string;
  why: string[];
  forecast: string;
  recommendedReleaseWindow?: string;
  suggestedActions: string[];
  ownerHints: string[];
  generatedAt: string;
}

export interface AgentRiskInterpretResponse {
  ok: true;
  result: AgentRiskInsight;
  meta: {
    model: string;
    inputTokens: number;
    outputTokens: number;
    durationMs: number;
  };
}

export interface AgentRiskInterpretError {
  ok: false;
  error: string;
  code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
}
  • Step 2: Write failing server test

Add to apps/server/src/modules/ai/ai.service.spec.ts:

it('interprets version risk through the active provider', async () => {
  const gateway = {
    getActiveProvider: jest.fn().mockResolvedValue({
      callTool: jest.fn().mockResolvedValue({
        toolInput: {
          summary: '预计延期',
          why: ['P1 Bug 增加'],
          forecast: '预计 2026-07-04 可发',
          recommendedReleaseWindow: '2026-07-04 之后',
          suggestedActions: ['优先修复 P1 Bug'],
          ownerHints: ['测试负责人同步失败用例'],
          generatedAt: '2026-06-29T01:00:00.000Z',
        },
        rawModel: 'test-model',
        inputTokens: 10,
        outputTokens: 20,
      }),
    }),
    getActiveModel: jest.fn().mockResolvedValue('test-model'),
  } as any;
  const service = new AiService(gateway);

  const result = await service.interpretRisk({
    versionId: 'ver-1',
    versionName: 'V1.0',
    riskScore: 70,
    riskLevel: 'at_risk',
    delayDays: 1,
    confidence: 80,
    signals: {
      unfinishedCount: 4,
      openBugCount: 3,
      criticalBugCount: 3,
      failedTestCount: 1,
      blockedCount: 0,
      silentRiskCount: 0,
      daysToExpectedRelease: 1,
    },
    trendSummary: '风险上升',
    reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }],
    silentRisks: [],
    dailyEvidence: {
      todayDeliveries: [],
      todayProgress: [],
      todayRisks: ['测试不通过'],
      progressNotes: [],
      needsProgressItems: [],
      recentActivityCount: 1,
    },
  });

  expect(result.ok).toBe(true);
  if (result.ok) expect(result.result.summary).toBe('预计延期');
});
  • Step 3: Run server test to verify failure

Run:

pnpm --filter server test -- ai.service

Expected: FAIL because interpretRisk does not exist.

  • Step 4: Implement DTO, prompt, service, and controller

Create apps/server/src/modules/ai/dto/risk-interpret.dto.ts:

import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class RiskReasonDto {
  @IsString() key!: string;
  @IsString() label!: string;
  @IsString() severity!: string;
  @IsString() detail!: string;
}

class RiskDailyEvidenceDto {
  @IsArray() todayDeliveries!: string[];
  @IsArray() todayProgress!: string[];
  @IsArray() todayRisks!: string[];
  @IsArray() progressNotes!: string[];
  @IsArray() needsProgressItems!: string[];
  @IsNumber() recentActivityCount!: number;
  @IsOptional() @IsString() lastActivityAt?: string;
}

class RiskSignalsDto {
  @IsNumber() unfinishedCount!: number;
  @IsNumber() openBugCount!: number;
  @IsNumber() criticalBugCount!: number;
  @IsNumber() failedTestCount!: number;
  @IsNumber() blockedCount!: number;
  @IsNumber() silentRiskCount!: number;
  @IsOptional() @IsNumber() daysToExpectedRelease?: number;
}

export class RiskInterpretDto {
  @IsString() versionId!: string;
  @IsString() versionName!: string;
  @IsOptional() @IsString() productName?: string;
  @IsOptional() @IsString() projectName?: string;
  @IsNumber() riskScore!: number;
  @IsString() riskLevel!: string;
  @IsOptional() @IsString() expectedReleaseDate?: string;
  @IsOptional() @IsString() forecastReleaseDate?: string;
  @IsNumber() delayDays!: number;
  @IsNumber() confidence!: number;
  @ValidateNested() @Type(() => RiskSignalsDto) signals!: RiskSignalsDto;
  @IsString() trendSummary!: string;
  @IsArray() @ValidateNested({ each: true }) @Type(() => RiskReasonDto) reasons!: RiskReasonDto[];
  @IsArray() silentRisks!: Array<{ key: string; days: number; detail: string }>;
  @ValidateNested() @Type(() => RiskDailyEvidenceDto) dailyEvidence!: RiskDailyEvidenceDto;
}

Create apps/server/src/modules/ai/prompts/risk-interpret.ts:

export const RISK_INTERPRET_TOOL_NAME = 'submit_risk_interpretation';
export const RISK_INTERPRET_TOOL_DESCRIPTION = '返回小宝预警的结构化解释';

export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的“小宝预警”解释助手。
你只能解释系统给出的规则预测结果和证据,不能编造不存在的数据。
你需要用项目经理能理解的中文说明:
1. 当前能不能按期发版;
2. 为什么有风险;
3. 预计延期或建议发版窗口;
4. 需要优先处理的动作;
5. 哪些负责人或角色需要关注。
输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。
如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。`;

export const RISK_INTERPRET_TOOL_INPUT_SCHEMA = {
  type: 'object',
  properties: {
    summary: { type: 'string' },
    why: { type: 'array', items: { type: 'string' } },
    forecast: { type: 'string' },
    recommendedReleaseWindow: { type: 'string' },
    suggestedActions: { type: 'array', items: { type: 'string' } },
    ownerHints: { type: 'array', items: { type: 'string' } },
    generatedAt: { type: 'string' },
  },
  required: ['summary', 'why', 'forecast', 'suggestedActions', 'ownerHints', 'generatedAt'],
};

Modify apps/server/src/modules/ai/ai.service.ts imports:

import {
  RISK_INTERPRET_SYSTEM_PROMPT,
  RISK_INTERPRET_TOOL_NAME,
  RISK_INTERPRET_TOOL_DESCRIPTION,
  RISK_INTERPRET_TOOL_INPUT_SCHEMA,
} from './prompts/risk-interpret';
import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError, AgentRiskInsight } from '@ftb/shared';

Add method to AiService:

  async interpretRisk(req: AgentRiskInterpretRequest): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
    const startedAt = Date.now();
    let provider;
    let model: string;
    try {
      provider = await this.gateway.getActiveProvider();
      model = await this.gateway.getActiveModel();
    } catch (e: any) {
      return { ok: false, error: e?.message || '未配置 AI 提供商', code: 'NO_PROVIDER' };
    }

    try {
      const toolResp = await provider.callTool({
        systemPrompt: RISK_INTERPRET_SYSTEM_PROMPT,
        userPrompt: `请基于以下小宝预警规则结果生成解释,禁止编造新事实:\n${JSON.stringify(req, null, 2)}`,
        tool: {
          name: RISK_INTERPRET_TOOL_NAME,
          description: RISK_INTERPRET_TOOL_DESCRIPTION,
          inputSchema: RISK_INTERPRET_TOOL_INPUT_SCHEMA,
        },
        forceTool: true,
        maxTokens: 3000,
        model,
      });
      const result = toolResp.toolInput as AgentRiskInsight;
      if (!result.summary || !Array.isArray(result.why) || !Array.isArray(result.suggestedActions)) {
        return { ok: false, error: 'AI 返回结构不符合预期', code: 'PARSE_ERROR' };
      }
      return {
        ok: true,
        result,
        meta: {
          model: toolResp.rawModel || model,
          inputTokens: toolResp.inputTokens,
          outputTokens: toolResp.outputTokens,
          durationMs: Date.now() - startedAt,
        },
      };
    } catch (e: any) {
      return { ok: false, error: `AI 服务调用失败:${e.message}`, code: 'API_ERROR' };
    }
  }

Modify apps/server/src/modules/ai/ai.controller.ts:

import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type { AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';

  @Post('risk-interpret')
  async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
    return this.aiService.interpretRisk(dto as any);
  }
  • Step 5: Run tests and commit

Run:

pnpm --filter server test -- ai.service
pnpm --filter server type-check
pnpm --filter web type-check

Expected: PASS.

Commit:

git add packages/shared/src/agent.ts apps/server/src/modules/ai/dto/risk-interpret.dto.ts apps/server/src/modules/ai/prompts/risk-interpret.ts apps/server/src/modules/ai/ai.service.ts apps/server/src/modules/ai/ai.controller.ts apps/server/src/modules/ai/ai.service.spec.ts
git commit -m "feat(小宝预警): 增加 AI 风险解读接口"

Task 6: Client AI Trigger And Cache Orchestration

Files:

  • Create: apps/web/lib/xiaobao-risk-ai.ts

  • Create: apps/web/lib/xiaobao-risk-ai.test.ts

  • Step 1: Write failing AI trigger tests

Create apps/web/lib/xiaobao-risk-ai.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldRequestRiskInsight } from './xiaobao-risk-ai';

test('shouldRequestRiskInsight skips on_track', () => {
  assert.equal(shouldRequestRiskInsight({ riskLevel: 'on_track', riskScore: 10, confidence: 90 } as any, undefined), false);
});

test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
  assert.equal(
    shouldRequestRiskInsight(
      { riskLevel: 'attention', riskScore: 68, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', signals: { unfinishedCount: 0, openBugCount: 0, criticalBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } } as any,
      { riskScore: 35, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } as any,
    ),
    true,
  );
});

test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
  const current = {
    riskLevel: 'attention',
    riskScore: 52,
    confidence: 80,
    forecastReleaseDate: '2026-07-04T10:00:00.000Z',
    signals: {
      unfinishedCount: 4,
      openBugCount: 3,
      criticalBugCount: 3,
      failedTestCount: 1,
      blockedCount: 1,
      silentRiskCount: 1,
      daysToExpectedRelease: 1,
    },
  } as any;
  const previous = {
    riskScore: 50,
    confidence: 80,
    forecastReleaseDate: '2026-07-02T10:00:00.000Z',
    openBugCount: 0,
    failedTestCount: 0,
    blockedCount: 0,
    silentRiskCount: 0,
  } as any;

  assert.equal(shouldRequestRiskInsight(current, previous), true);
});

test('shouldRequestRiskInsight triggers high risk levels', () => {
  assert.equal(shouldRequestRiskInsight({ riskLevel: 'at_risk', riskScore: 60, confidence: 80 } as any, undefined), true);
  assert.equal(shouldRequestRiskInsight({ riskLevel: 'likely_delayed', riskScore: 80, confidence: 80 } as any, undefined), true);
  assert.equal(shouldRequestRiskInsight({ riskLevel: 'blocked', riskScore: 90, confidence: 80 } as any, undefined), true);
});
  • Step 2: Run trigger test to verify failure

Run:

pnpm --filter web test -- xiaobao-risk-ai

Expected: FAIL because apps/web/lib/xiaobao-risk-ai.ts does not exist.

  • Step 3: Implement AI trigger and request mapper

Create apps/web/lib/xiaobao-risk-ai.ts:

import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';
import { api } from './api';
import type { XiaobaoVersionRisk } from './xiaobao-risk';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
import { buildRiskSignature } from './xiaobao-risk-trend';
import { findCachedInsight } from './xiaobao-risk-cache';

type RiskInsightCurrent = Pick<XiaobaoVersionRisk, 'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals'>;
type RiskInsightPrevious = Pick<XiaobaoRiskSnapshot, 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'openBugCount' | 'failedTestCount' | 'blockedCount' | 'silentRiskCount'>;

export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
  if (current.riskLevel === 'on_track') return false;
  if (current.riskLevel === 'at_risk' || current.riskLevel === 'likely_delayed' || current.riskLevel === 'blocked') return true;
  const releaseIsTomorrowWithUnfinishedWork =
    current.signals.daysToExpectedRelease !== undefined &&
    current.signals.daysToExpectedRelease <= 1 &&
    current.signals.unfinishedCount > 0;
  if (!previous) return releaseIsTomorrowWithUnfinishedWork;
  const scoreDelta = previous ? current.riskScore - previous.riskScore : 0;
  const confidenceDelta = previous ? previous.confidence - current.confidence : 0;
  const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate
    ? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime()
    : 0;
  return (
    scoreDelta >= 15 ||
    confidenceDelta >= 15 ||
    forecastDelayMs >= 86400000 ||
    current.signals.criticalBugCount > previous.openBugCount ||
    current.signals.failedTestCount > previous.failedTestCount ||
    current.signals.blockedCount > previous.blockedCount ||
    current.signals.silentRiskCount > previous.silentRiskCount ||
    releaseIsTomorrowWithUnfinishedWork
  );
}

export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
  return buildRiskSignature({
    versionId: risk.versionId,
    riskScore: risk.riskScore,
    riskLevel: risk.riskLevel,
    forecastReleaseDate: risk.forecastReleaseDate,
    openBugCount: risk.signals.criticalBugCount,
    failedTestCount: risk.signals.failedTestCount,
    blockedCount: risk.signals.blockedCount,
    silentRiskCount: risk.signals.silentRiskCount,
    confidence: risk.confidence,
  });
}

export function getReusableInsight(cache: XiaobaoRiskInsightCacheItem[], risk: XiaobaoVersionRisk): XiaobaoRiskInsightCacheItem | undefined {
  return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
}

export async function requestRiskInsight(risk: XiaobaoVersionRisk): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
  const payload: AgentRiskInterpretRequest = {
    versionId: risk.versionId,
    versionName: risk.versionName,
    productName: risk.productName,
    projectName: risk.projectName,
    riskScore: risk.riskScore,
    riskLevel: risk.riskLevel,
    expectedReleaseDate: risk.expectedReleaseDate,
    forecastReleaseDate: risk.forecastReleaseDate,
    delayDays: risk.delayDays,
    confidence: risk.confidence,
    signals: risk.signals,
    trendSummary: risk.trend.summary,
    reasons: risk.reasons,
    silentRisks: risk.silentRisks,
    dailyEvidence: {
      todayDeliveries: risk.dailyEvidence.todayDeliveries.map((item) => item.summary),
      todayProgress: risk.dailyEvidence.todayProgress.map((item) => item.summary),
      todayRisks: risk.dailyEvidence.todayRisks.map((item) => item.summary),
      progressNotes: risk.dailyEvidence.progressNotes.map((item) => item.summary),
      needsProgressItems: risk.dailyEvidence.needsProgressItems.map((item) => item.summary),
      recentActivityCount: risk.dailyEvidence.recentActivityCount,
      lastActivityAt: risk.dailyEvidence.lastActivityAt,
    },
  };
  return api.post('/ai/risk-interpret', payload);
}

  • Step 4: Run tests and commit

Run:

pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check

Expected: PASS.

Commit:

git add apps/web/lib/xiaobao-risk-ai.ts apps/web/lib/xiaobao-risk-ai.test.ts
git commit -m "feat(小宝预警): 增加 AI 解读触发策略"

Task 7: Xiaobao Warning Page And Navigation

Files:

  • Modify: apps/web/components/layout/Sidebar.tsx

  • Create: apps/web/app/xiaobao-warning/page.tsx

  • Create: apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx

  • Create: apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx

  • Step 1: Add navigation item

Modify apps/web/components/layout/Sidebar.tsx imports:

import { AlertTriangle, Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';

Add item before 与我相关:

      { label: '小宝预警', path: '/xiaobao-warning', icon: AlertTriangle, permission: 'xiaobao.warning:view' },
      { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
  • Step 2: Create card component

Create apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx:

'use client';

import { AlertTriangle, ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';

const LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
  on_track: '可按期',
  attention: '关注',
  at_risk: '高风险',
  likely_delayed: '预计延期',
  blocked: '阻塞',
};

const LEVEL_STYLE: Record<XiaobaoVersionRisk['riskLevel'], string> = {
  on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200',
  attention: 'bg-amber-50 text-amber-700 border-amber-200',
  at_risk: 'bg-orange-50 text-orange-700 border-orange-200',
  likely_delayed: 'bg-red-50 text-red-700 border-red-200',
  blocked: 'bg-red-100 text-red-800 border-red-300',
};

export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) {
  const TrendIcon = risk.trend.direction === 'up' ? ArrowUpRight : risk.trend.direction === 'down' ? ArrowDownRight : ArrowRight;
  return (
    <button onClick={onClick} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-left transition-colors hover:border-[var(--accent)]">
      <div className="flex items-start gap-3">
        <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-orange-50 text-orange-600">
          <AlertTriangle className="h-4 w-4" />
        </div>
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
            <span className={`rounded-md border px-2 py-0.5 text-[11px] font-medium ${LEVEL_STYLE[risk.riskLevel]}`}>{LEVEL_LABEL[risk.riskLevel]}</span>
          </div>
          <p className="mt-1 truncate text-[11px] text-[var(--ink-muted)]">{[risk.productName, risk.projectName].filter(Boolean).join(' / ')}</p>
          <div className="mt-3 grid grid-cols-4 gap-3 text-[11px]">
            <Metric label="风险分" value={String(risk.riskScore)} />
            <Metric label="预计可发" value={risk.forecastReleaseDate?.slice(0, 10) ?? '-'} />
            <Metric label="延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '无'} />
            <Metric label="置信度" value={`${risk.confidence}%`} />
          </div>
          <div className="mt-3 flex items-center gap-1.5 text-[11px] text-[var(--ink-soft)]">
            <TrendIcon className="h-3.5 w-3.5" />
            <span>{risk.trend.summary}</span>
          </div>
          {risk.reasons[0] && <p className="mt-2 line-clamp-2 text-[12px] text-[var(--ink-soft)]">{risk.reasons[0].detail}</p>}
        </div>
      </div>
    </button>
  );
}

function Metric({ label, value }: { label: string; value: string }) {
  return (
    <div>
      <p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
      <p className="mt-0.5 font-semibold tabular-nums text-[var(--ink)]">{value}</p>
    </div>
  );
}
  • Step 3: Create drawer component

Create apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx:

'use client';

import { X } from 'lucide-react';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';

export function XiaobaoWarningDrawer({ risk, onClose, onNavigate }: { risk: XiaobaoVersionRisk; onClose: () => void; onNavigate: () => void }) {
  return (
    <div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
      <div className="h-full w-full max-w-md border-l border-[var(--line)] bg-[var(--bg)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
        <div className="flex h-14 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
          <div>
            <h2 className="text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h2>
            <p className="text-[11px] text-[var(--ink-muted)]">小宝预警详情</p>
          </div>
          <button onClick={onClose} className="rounded p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
            <X className="h-4 w-4" />
          </button>
        </div>
        <div className="h-[calc(100%-3.5rem)] overflow-y-auto p-5">
          <Section title="预测依据">
            {risk.reasons.length === 0 ? <Empty text="暂无明显风险" /> : risk.reasons.map((reason) => (
              <div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
                <p className="text-[12px] font-medium text-[var(--ink)]">{reason.label}</p>
                <p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
              </div>
            ))}
          </Section>
          <Section title="日报证据">
            {[...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].length === 0
              ? <Empty text="暂无近期日报证据" />
              : [...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].map((item) => (
                <div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-soft)]">{item.summary}</div>
              ))}
          </Section>
          <Section title="静默风险">
            {risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item) => (
              <div key={item.key} className="rounded-lg border border-orange-200 bg-orange-50 p-3 text-[12px] text-orange-800">{item.detail}</div>
            ))}
          </Section>
          <Section title="风险趋势">
            <div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
              {risk.trend.summary}
            </div>
          </Section>
          <button onClick={onNavigate} className="mt-4 h-9 w-full rounded-lg bg-[var(--accent)] text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]">打开版本详情</button>
        </div>
      </div>
    </div>
  );
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <section className="mb-5">
      <h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
      <div className="space-y-2">{children}</div>
    </section>
  );
}

function Empty({ text }: { text: string }) {
  return <div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">{text}</div>;
}
  • Step 4: Create page

Create apps/web/app/xiaobao-warning/page.tsx:

'use client';

import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';

export default function XiaobaoWarningPage() {
  return (
    <RouteGuard permission="xiaobao.warning:view">
      <XiaobaoWarningContent />
    </RouteGuard>
  );
}

function XiaobaoWarningContent() {
  const router = useRouter();
  const canManage = useHasPermission('xiaobao.warning:manage');
  const user = useAuthStore((s) => s.user);
  const { overview, fetchOverview } = useProductStore();
  const { plans, fetchPlans } = useVersionPlanStore();
  const { requirements, fetchRequirements } = useRequirementStore();
  const { tasks: devTasks, fetchTasks } = useDevTaskStore();
  const { testCases, fetchTestCases } = useTestCaseStore();
  const { bugs, fetchBugs } = useBugStore();
  const { activities, fetchActivities } = useWorkActivityStore();
  const { worklogs, fetchWorklogs } = useTaskWorklogStore();
  const [selected, setSelected] = useState<XiaobaoVersionRisk | null>(null);

  useEffect(() => { fetchOverview(); }, [fetchOverview]);
  useEffect(() => { fetchPlans(); }, [fetchPlans]);
  useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
  useEffect(() => { fetchTasks(); }, [fetchTasks]);
  useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
  useEffect(() => { fetchBugs(); }, [fetchBugs]);
  useEffect(() => { fetchActivities(); }, [fetchActivities]);
  useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);

  const risks = useMemo(() => {
    const versions = flattenVersions(overview)
      .filter((version) => ['planned', 'developing', 'paused'].includes(version.status))
      .filter((version) => canManage || (version.members ?? []).some((member) => member.name === user?.name));
    const versionMap = new Map(versions.map((v) => [v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }]));
    const requirementVersionMap = new Map<string, string>();
    requirements.forEach((requirement) => { if (requirement.versionId) requirementVersionMap.set(requirement.id, requirement.versionId); });
    const workItems = buildXiaobaoWorkItems({
      plans,
      devTasks,
      testCases,
      bugs,
      versionMap,
      requirementVersionMap,
    });
    return versions.map((version) => {
      const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id);
      const reqIds = new Set(versionRequirements.map((requirement) => requirement.id));
      const evidence = buildVersionDailyEvidence({ versionId: version.id, workItems, activities, worklogs });
      return calcXiaobaoVersionRisk({
        version,
        plans: plans.filter((plan) => plan.versionId === version.id),
        requirements: versionRequirements,
        devTasks: devTasks.filter((task) => reqIds.has(task.requirementId)),
        testCases: testCases.filter((testCase) => testCase.versionId === version.id),
        bugs: bugs.filter((bug) => bug.versionId === version.id),
        dailyEvidence: evidence,
        snapshots: [],
      });
    }).sort((a, b) => b.riskScore - a.riskScore);
  }, [overview, canManage, user?.name, requirements, plans, devTasks, testCases, bugs, activities, worklogs]);

  return (
    <div className="flex h-full flex-col bg-[var(--bg)]">
      <header className="flex h-14 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
        <div>
          <h1 className="text-[15px] font-semibold text-[var(--ink)]">小宝预警</h1>
          <p className="text-[11px] text-[var(--ink-muted)]">{canManage ? '管理视角:全部版本' : '个人视角:我参与的版本'}</p>
        </div>
        <span className="text-[12px] text-[var(--ink-muted)]">{risks.length} 个版本</span>
      </header>
      <main className="flex-1 overflow-y-auto p-5">
        {risks.length === 0 ? (
          <div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">暂无小宝预警</div>
        ) : (
          <div className="grid gap-3 xl:grid-cols-2">
            {risks.map((risk) => <XiaobaoWarningCard key={risk.versionId} risk={risk} onClick={() => setSelected(risk)} />)}
          </div>
        )}
      </main>
      {selected && (
        <XiaobaoWarningDrawer
          risk={selected}
          onClose={() => setSelected(null)}
          onNavigate={() => router.push(`/versions/${selected.versionId}`)}
        />
      )}
    </div>
  );
}
  • Step 5: Run UI checks and commit

Run:

pnpm --filter web type-check
pnpm --filter web test -- xiaobao-risk
curl http://localhost:3000/xiaobao-warning

Expected: type-check PASS, tests PASS, curl returns HTML with HTTP 200 when dev server is running.

Commit:

git add apps/web/components/layout/Sidebar.tsx apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
git commit -m "feat(小宝预警): 增加预警页面"

Task 8: Automatic AI Interpretation In The Page

Files:

  • Modify: apps/web/app/xiaobao-warning/page.tsx

  • Modify: apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx

  • Modify: apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx

  • Step 1: Wire store, cache, and AI calls into page

Modify apps/web/app/xiaobao-warning/page.tsx imports:

import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { buildRiskInsightSignature, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';

Inside XiaobaoWarningContent:

  const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
  useEffect(() => { fetchRiskData(); }, [fetchRiskData]);

Pass snapshots into risk calculation:

        snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id),

Add effect after risks:

  useEffect(() => {
    const today = new Date().toISOString().slice(0, 10);
    risks.forEach((risk) => {
      saveSnapshot({
        versionId: risk.versionId,
        date: today,
        riskScore: risk.riskScore,
        riskLevel: risk.riskLevel,
        forecastReleaseDate: risk.forecastReleaseDate,
        openBugCount: risk.signals.criticalBugCount,
        failedTestCount: risk.signals.failedTestCount,
        blockedCount: risk.signals.blockedCount,
        silentRiskCount: risk.signals.silentRiskCount,
        confidence: risk.confidence,
        createdAt: new Date().toISOString(),
      }).catch(() => {});
    });
  }, [risks, saveSnapshot]);

  useEffect(() => {
    risks.forEach((risk) => {
      const cached = getReusableInsight(insights, risk);
      const today = new Date().toISOString().slice(0, 10);
      const previous = snapshots
        .filter((snapshot) => snapshot.versionId === risk.versionId && snapshot.date < today)
        .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
      if (cached || !shouldRequestRiskInsight(risk, previous)) return;
      requestRiskInsight(risk).then((res) => {
        if (!res.ok) return;
        saveInsight({
          versionId: risk.versionId,
          riskSignature: buildRiskInsightSignature(risk),
          insight: res.result,
          generatedAt: new Date().toISOString(),
          providerInfo: { model: res.meta.model },
        }).catch(() => {});
      }).catch(() => {});
    });
  }, [risks, insights, snapshots, saveInsight]);

Map cached insight into selected risk by extending local state with cache lookup in render:

  const risksWithInsight = useMemo(
    () => risks.map((risk) => ({ ...risk, aiInsight: getReusableInsight(insights, risk)?.insight })),
    [risks, insights],
  );

  const selectedWithInsight = selected
    ? { ...selected, aiInsight: getReusableInsight(insights, selected)?.insight }
    : null;

Use risksWithInsight for card rendering and selectedWithInsight for drawer rendering:

{risksWithInsight.map((risk) => <XiaobaoWarningCard key={risk.versionId} risk={risk} onClick={() => setSelected(risk)} />)}

{selectedWithInsight && (
  <XiaobaoWarningDrawer
    risk={selectedWithInsight}
    onClose={() => setSelected(null)}
    onNavigate={() => router.push(`/versions/${selectedWithInsight.versionId}`)}
  />
)}
  • Step 2: Show AI insight on card and drawer

Modify XiaobaoWarningCard after first reason:

          {risk.aiInsight?.summary && <p className="mt-2 line-clamp-2 text-[12px] text-[var(--accent)]">{risk.aiInsight.summary}</p>}

Modify XiaobaoWarningDrawer before the navigation button:

          <Section title="小宝建议">
            {risk.aiInsight ? (
              <div className="space-y-2">
                <div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
                  <p className="text-[12px] font-medium text-[var(--ink)]">{risk.aiInsight.summary}</p>
                  <p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
                </div>
                {risk.aiInsight.suggestedActions.map((action) => (
                  <div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-soft)]">{action}</div>
                ))}
              </div>
            ) : (
              <Empty text="规则预警已生成AI 解读会在触发条件满足时自动补充" />
            )}
          </Section>
  • Step 3: Run verification and commit

Run:

pnpm --filter web test -- xiaobao-risk-ai
pnpm --filter web type-check

Expected: PASS.

Commit:

git add apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
git commit -m "feat(小宝预警): 自动生成 AI 风险解读"

Task 9: Documentation Updates

Files:

  • Modify: docs/architecture.md

  • Modify: docs/decisions.md

  • Modify: docs/workflow.md

  • Modify: docs/roadmap.md

  • Modify: docs/agent-spec.md

  • Step 1: Update architecture

Add a section to docs/architecture.md near “AI Agent 层”:

## 小宝预警层

小宝预警是版本级风险预测能力,位于主导航“工作区 / 小宝预警”。它不替代版本健康度,而是在版本维度回答“能不能按期发版、为什么有风险、预计延期到多久、风险趋势如何”。

核心规则位于前端纯函数引擎:

- `xiaobao-risk.ts`:版本风险分、等级、预计可发日期、置信度。
- `xiaobao-risk-evidence.ts`:日报和工作活动证据、静默风险。
- `xiaobao-risk-trend.ts`:风险快照、趋势和 AI 解读签名。

AI 只解释规则结果不直接修改业务实体。AI 解读缓存写入 `xiaobao-risk-insights`,风险趋势快照写入 `xiaobao-risk-snapshots`
  • Step 2: Update decisions

Append to docs/decisions.md:

## 33. 小宝预警规则优先AI 只解释

**问题**:如果直接让 AI 判断版本能否发版模型可能忽略系统内的任务、Bug、日报和权限事实导致不可追溯的结论。

**决策**:小宝预警先由确定性规则计算 `riskScore``riskLevel``forecastReleaseDate``confidence`、趋势和静默风险。AI 只在规则触发后解释这些事实,不直接修改任何业务数据。

**理由**规则结果可测试、可追溯AI 文案可提升可读性但不能替代系统判断。
  • Step 3: Update workflow

Add to docs/workflow.md:

## 小宝预警工作流

小宝预警位于主导航“工作区 / 小宝预警”,显示在“与我相关”上方。可见范围由角色权限控制:

- `xiaobao.warning:manage`:查看所有未结束版本预警。
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的版本。

页面打开时会计算当前可见版本风险,并写入当天风险快照。风险达到 `at_risk``likely_delayed``blocked`,或 `attention` 出现明显风险变化时,会自动生成 AI 解读。AI 解读只写入缓存,不修改版本或任务。
  • Step 4: Update roadmap and agent spec

In docs/roadmap.md, replace the V3.2 Risk Watch Agent placeholder with:

### V3.2 — 小宝预警 / Risk Watch Agent

小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版不做后台定时 Agent风险快照在用户打开页面时写入。

In docs/agent-spec.md, replace “Agent 2Risk Watch Agent风险预警— 待规划” with:

### 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:

$terms = @('TO' + 'DO', 'TB' + 'D', '待' + '定', '占' + '位', '未' + '确定')
rg -n ($terms -join '|') docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md

Expected: no matches introduced by this task.

Commit:

git add docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md
git commit -m "docs(小宝预警): 更新架构与 Agent 规范"

Task 10: Final Verification

Files:

  • No new files.

  • Step 1: Run full web verification

Run:

pnpm --filter web test
pnpm --filter web type-check

Expected: both commands PASS.

  • Step 2: Run server verification

Run:

pnpm --filter server test
pnpm --filter server type-check
pnpm --filter server exec prisma validate --schema prisma/schema.prisma

Expected: all commands PASS.

  • Step 3: Check route manually

Run when the web dev server is already running:

curl http://localhost:3000/xiaobao-warning

Expected: HTTP 200 with HTML content.

  • Step 4: Inspect git status

Run:

git status --short

Expected: only pre-existing unrelated local changes remain, or a clean working tree if this feature was implemented in an isolated worktree.

  • Step 5: Final commit if verification-only fixes were needed

If verification required small fixes, commit only those feature files:

git add <files fixed during verification>
git commit -m "fix(小宝预警): 修复验证问题"

Expected: commit succeeds, or no commit is needed because no fixes were required.