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