diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx
index 85b991d..ae7d6c3 100644
--- a/apps/web/components/dev-task/DevTaskRow.tsx
+++ b/apps/web/components/dev-task/DevTaskRow.tsx
@@ -3,7 +3,7 @@
import { AlertTriangle } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
-import { getEstimateHours, getActualHours } from '@/lib/dev-task';
+import { getEstimateHours, getActualHours, needsDevTaskClaim } from '@/lib/dev-task';
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
import type { DevTask } from '@/lib/dev-task';
import type { TaskCategory } from '@/lib/task-category';
@@ -11,6 +11,7 @@ import type { TaskCategory } from '@/lib/task-category';
interface Props {
task: DevTask;
category?: TaskCategory;
+ categoryLabelWidthEm?: number;
onClick?: () => void;
}
@@ -45,37 +46,64 @@ function hoursText(task: DevTask, estimate: number, actual: number): { text: str
return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone };
}
-export function DevTaskRow({ task, category, onClick }: Props) {
+export function DevTaskRow({ task, category, categoryLabelWidthEm, onClick }: Props) {
const estimate = getEstimateHours(task);
const actual = getActualHours(task);
const range = timeRangeText(task);
const hours = hoursText(task, estimate, actual);
+ const needsClaim = needsDevTaskClaim(task);
+ const labelWidthStyle = categoryLabelWidthEm ? { width: `${categoryLabelWidthEm}em` } : undefined;
return (
-
-
{task.taskNo}
-
-
{task.title}
- {task.aiDraft && (
-
- AI 草案
-
- )}
- {task.isBlocked && (
-
- 阻塞
-
- )}
+
+
+
{task.taskNo}
+
+
+ {task.title}
+
+ {range.text && (
+ {range.text}
+ )}
+ {hours.text}
+
+
+
+ {task.isBlocked && (
+
+ 阻塞
+
+ )}
+ {task.aiDraft && (
+
+ AI 草案
+
+ )}
+ {!needsClaim && (
+
负责人:{task.assigneeId}
+ )}
+ {needsClaim ? (
+
+ 待领取
+
+ ) : (
+
+ )}
+
+
+
-
-
-
{range.text}
-
{hours.text}
-
{task.assigneeId}
);
}
diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx
index c8fe2e8..aa2742e 100644
--- a/apps/web/components/dev-task/DevTaskTab.tsx
+++ b/apps/web/components/dev-task/DevTaskTab.tsx
@@ -111,6 +111,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
}, [paged]);
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
+ const categoryLabelWidthEm = useMemo(
+ () => Math.max(4, ...categories.map((category) => category.name.length)) + 1,
+ [categories],
+ );
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]);
@@ -179,11 +183,16 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
const reqProgress = calcGroupProgress(reqTasks);
return (
-
-
selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
-
{req?.code}
-
{req?.title}
-
{reqProgress}%
+
+
+ selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
+
+
+
+ {req?.code}
+ {req?.title}
+ {reqProgress}%
+
{reqTasks.map((t) => (
@@ -191,7 +200,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- setSelectedTaskId(t.id)} />
+ setSelectedTaskId(t.id)} />
))}
diff --git a/apps/web/components/dev-task/StatusBadge.tsx b/apps/web/components/dev-task/StatusBadge.tsx
index fb2ea64..7159c41 100644
--- a/apps/web/components/dev-task/StatusBadge.tsx
+++ b/apps/web/components/dev-task/StatusBadge.tsx
@@ -3,9 +3,12 @@
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
import type { DevTaskStatus } from '@/lib/dev-task';
-export function StatusBadge({ status }: { status: DevTaskStatus }) {
+export function StatusBadge({ status, widthEm }: { status: DevTaskStatus; widthEm?: number }) {
return (
-
+
{DEV_TASK_STATUS_LABEL[status]}
);
diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx
index d1b8497..e675ce1 100644
--- a/apps/web/components/test-case/TestCaseCreateModal.tsx
+++ b/apps/web/components/test-case/TestCaseCreateModal.tsx
@@ -26,6 +26,12 @@ function defaultPlannedTestLocal(): string {
return isoToLocal(d.toISOString());
}
+function defaultPlannedEndLocal(): string {
+ const d = new Date();
+ d.setHours(10, 0, 0, 0);
+ return isoToLocal(d.toISOString());
+}
+
export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) {
const { createTestCase } = useTestCaseStore();
const { requirements } = useRequirementStore();
@@ -49,6 +55,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
);
const [estimateHours, setEstimateHours] = useState(0.5);
const [plannedTestLocal, setPlannedTestLocal] = useState(defaultPlannedTestLocal);
+ const [plannedEndLocal, setPlannedEndLocal] = useState(defaultPlannedEndLocal);
const [assigneeId, setAssigneeId] = useState(user?.name || '');
const [description, setDescription] = useState('');
const [prototypeNotes, setPrototypeNotes] = useState('');
@@ -69,7 +76,9 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours);
const plannedTestAt = localToISO(plannedTestLocal);
- const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && Boolean(plannedTestAt);
+ const plannedEndAt = localToISO(plannedEndLocal);
+ const hasValidPlan = Boolean(plannedTestAt && plannedEndAt && plannedEndAt > plannedTestAt);
+ const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && hasValidPlan;
const handleSubmit = () => {
if (!canSubmit) return;
@@ -92,6 +101,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
priority,
estimateHours: normalizedEstimateHours,
plannedTestAt,
+ plannedEndAt,
assigneeId: assigneeId || undefined,
references: references.length > 0 ? references : undefined,
createdBy: user?.name || '系统',
@@ -154,15 +164,32 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
-
-
+
+
+
+
+
+
+
+
+
+ {!hasValidPlan && plannedTestLocal && plannedEndLocal && (
+
+ 计划开始必须早于计划结束
+
+ )}
- {nextStatuses.length > 0 && !showFailInput && !showBlockInput && (
+ {visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && (
- {nextStatuses.map((s) => (
+ {visibleNextStatuses.map((s) => (
@@ -133,6 +188,61 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
)}
+ {tc.status === 'pending' && !startReady && !showPlanInput && (
+
+
+
+ {needsClaim ? '领取时必须填写计划开始和计划结束' : '开始测试前需要补齐计划开始和计划结束'}
+
+
+ )}
+
+ {showPlanInput && (
+
+
+ {needsClaim ? '领取并填写计划' : '填写计划'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 执行预估:{planEstimateHours > 0 ? formatWorkHours(planEstimateHours) : '请选择有效起止时间'}
+
+
+
+
+
+
+
+ )}
+
{showFailInput && (
setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="不通过原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
@@ -157,10 +267,10 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
基本信息
-
计划测试:{tc.plannedTestAt ? formatDateTime(tc.plannedTestAt) : '待排期'}
+
计划测试:{planDisplay}
优先级:{tc.priority}
任务类型:
-
负责人:{tc.assigneeId || '-'}
+
负责人:{needsClaim ? '待领取' : tc.assigneeId}
AI 预估:{aiEstimateHours ? formatWorkHours(aiEstimateHours) : '—'}
执行预估:{executorEstimateHours ? formatWorkHours(executorEstimateHours) : '待负责人填写'}
创建人:{tc.createdBy}
diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx
index 05d860b..9b005ec 100644
--- a/apps/web/components/test-case/TestCaseRow.tsx
+++ b/apps/web/components/test-case/TestCaseRow.tsx
@@ -2,7 +2,7 @@
import { memo } from 'react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
-import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
+import { getTestCaseActualHours, getTestCaseEstimateHours, needsTestCaseClaim } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours';
import { formatDateTimeShort } from '@/lib/format';
import type { TestCase } from '@/lib/test-case';
@@ -11,6 +11,7 @@ import type { TaskCategory } from '@/lib/task-category';
interface Props {
testCase: TestCase;
category?: TaskCategory;
+ categoryLabelWidthEm?: number;
bugCount: number;
onClick?: () => void;
}
@@ -22,45 +23,74 @@ const PRIORITY_DOT: Record
= {
P3: 'bg-zinc-300',
};
-function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
+function TestCaseRowImpl({ testCase, category, categoryLabelWidthEm, bugCount, onClick }: Props) {
const estimateHours = getTestCaseEstimateHours(testCase);
const actualHours = getTestCaseActualHours(testCase);
+ const needsClaim = needsTestCaseClaim(testCase);
+ const labelWidthStyle = categoryLabelWidthEm ? { width: `${categoryLabelWidthEm}em` } : undefined;
const hasExecutorEstimate = typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0;
const hasAiEstimate = typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0;
const estimatePrefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预';
+ const planText = testCase.plannedTestAt && testCase.plannedEndAt
+ ? `${formatDateTimeShort(testCase.plannedTestAt)} → ${formatDateTimeShort(testCase.plannedEndAt)}`
+ : testCase.plannedTestAt ? formatDateTimeShort(testCase.plannedTestAt) : '';
return (
-
-
-
{testCase.caseNo}
-
- {category?.name || '未分类'}
-
-
-
{testCase.title}
- {testCase.aiDraft && (
-
- AI 草案
-
- )}
+
+
+
+
{testCase.caseNo}
+
+
+ {testCase.title}
+
+ {planText && (
+ {planText}
+ )}
+
+ {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`}
+
+
+
+
+ {bugCount > 0 && (
+ {bugCount} Bug
+ )}
+ {testCase.aiDraft && (
+
+ AI 草案
+
+ )}
+ {!needsClaim && (
+ 负责人:{testCase.assigneeId}
+ )}
+ {needsClaim ? (
+
+ 待领取
+
+ ) : (
+
+ )}
+
+ {category?.name || '未分类'}
+
+
+
- {bugCount > 0 && (
-
{bugCount} Bug
- )}
-
-
- {testCase.plannedTestAt ? formatDateTimeShort(testCase.plannedTestAt) : '待排期'}
-
-
- {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`}
-
-
{testCase.assigneeId || '-'}
);
}
diff --git a/apps/web/components/test-case/TestCaseStatusBadge.tsx b/apps/web/components/test-case/TestCaseStatusBadge.tsx
index 0371915..55f80fa 100644
--- a/apps/web/components/test-case/TestCaseStatusBadge.tsx
+++ b/apps/web/components/test-case/TestCaseStatusBadge.tsx
@@ -3,9 +3,12 @@
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
import type { TestCaseStatus } from '@/lib/test-case';
-export function TestCaseStatusBadge({ status }: { status: TestCaseStatus }) {
+export function TestCaseStatusBadge({ status, widthEm }: { status: TestCaseStatus; widthEm?: number }) {
return (
-
+
{TEST_CASE_STATUS_LABEL[status]}
);
diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx
index 4aa8e78..71ebf6b 100644
--- a/apps/web/components/test-case/TestCaseTab.tsx
+++ b/apps/web/components/test-case/TestCaseTab.tsx
@@ -152,6 +152,10 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
+ const categoryLabelWidthEm = useMemo(
+ () => Math.max(4, ...categories.map((category) => category.name.length)) + 1,
+ [categories],
+ );
const bugCountByCase = useMemo(() => {
const map = new Map();
for (const b of versionBugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
@@ -230,21 +234,19 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
-
+
{req?.code || '通用'}
- {deliveryStatus ? (
- {req?.title || '未关联需求'}
+ {deliveryStatus && (
+
{deliveryStatus === 'submitted' ? '已提测' : '待提测'}
- ) : (
-
)}
- {req?.title || '未关联需求'}
{reqPassed}/{cases.length} 通过
@@ -254,7 +256,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- setSelectedCaseId(c.id)} />
+ setSelectedCaseId(c.id)} />
))}
diff --git a/apps/web/lib/dev-task-workflow.test.ts b/apps/web/lib/dev-task-workflow.test.ts
index 4b9a6d0..c44418a 100644
--- a/apps/web/lib/dev-task-workflow.test.ts
+++ b/apps/web/lib/dev-task-workflow.test.ts
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import type { DevTask } from './dev-task';
+import { canStartDevTask, hasDevTaskPlan, needsDevTaskClaim, type DevTask } from './dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow';
function task(patch: Partial = {}): DevTask {
@@ -47,6 +47,44 @@ test('todo to in_progress writes actualStartAt from manual click time', () => {
assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z');
});
+test('AI dev task without an assignee must be claimed with a plan before starting', () => {
+ const draft = task({
+ assigneeId: '',
+ expectedStartAt: '',
+ expectedEndAt: '',
+ aiDraft: true,
+ });
+
+ assert.equal(needsDevTaskClaim(draft), true);
+ assert.equal(hasDevTaskPlan(draft), false);
+ assert.equal(canStartDevTask(draft), false);
+
+ const result = applyDevTaskTransition(draft, 'in_progress', {
+ now: new Date('2026-06-25T03:30:00.000Z'),
+ });
+
+ assert.equal(result.ok, false);
+});
+
+test('recommended assignee dev task still needs a plan before starting', () => {
+ const draft = task({
+ assigneeId: 'Alice',
+ expectedStartAt: '',
+ expectedEndAt: '',
+ aiDraft: true,
+ });
+
+ assert.equal(needsDevTaskClaim(draft), false);
+ assert.equal(hasDevTaskPlan(draft), false);
+ assert.equal(canStartDevTask(draft), false);
+
+ const result = applyDevTaskTransition(draft, 'in_progress', {
+ now: new Date('2026-06-25T03:30:00.000Z'),
+ });
+
+ assert.equal(result.ok, false);
+});
+
test('in_progress to testing does not write actualEndAt', () => {
const result = applyDevTaskTransition(task({
status: 'in_progress',
@@ -71,6 +109,21 @@ test('testing to submitted writes actualEndAt', () => {
assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z');
});
+test('blocked task cannot be submitted to test until unblocked', () => {
+ const result = applyDevTaskTransition(task({
+ status: 'testing',
+ actualStartAt: '2026-06-25T03:30:00.000Z',
+ isBlocked: true,
+ blockReason: '等待接口联调',
+ }), 'submitted', {
+ now: new Date('2026-06-25T05:00:00.000Z'),
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.patch, undefined);
+ assert.match(result.message || '', /\u963b\u585e/);
+});
+
test('invalid transition is rejected', () => {
const result = applyDevTaskTransition(task(), 'submitted', {
now: new Date('2026-06-25T05:00:00.000Z'),
diff --git a/apps/web/lib/dev-task-workflow.ts b/apps/web/lib/dev-task-workflow.ts
index 78d2519..acefd86 100644
--- a/apps/web/lib/dev-task-workflow.ts
+++ b/apps/web/lib/dev-task-workflow.ts
@@ -1,5 +1,5 @@
import type { DevTask, DevTaskStatus } from './dev-task';
-import { canTransition } from './dev-task';
+import { canStartDevTask, canTransition } from './dev-task';
export interface DevTaskWorkflowResult {
ok: boolean;
@@ -30,7 +30,15 @@ export function applyDevTaskTransition(
return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
}
+ if (to === 'submitted' && task.isBlocked) {
+ return { ok: false, message: '任务仍处于阻塞中,请先解除阻塞后再提测' };
+ }
+
const nowIso = (options.now ?? new Date()).toISOString();
+ if (to === 'in_progress' && !canStartDevTask(task)) {
+ return { ok: false, message: '开始开发前需要先领取并填写预计开始和预计截止时间' };
+ }
+
const patch: Partial = {
status: to,
aiDraft: false,
diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts
index 9f3dfe2..d7d80ad 100644
--- a/apps/web/lib/dev-task.ts
+++ b/apps/web/lib/dev-task.ts
@@ -112,6 +112,21 @@ function roundEffortHours(hours: number): number {
return Number(hours.toFixed(2));
}
+export function needsDevTaskClaim(task: Pick): boolean {
+ return !task.assigneeId?.trim();
+}
+
+export function hasDevTaskPlan(task: Pick): boolean {
+ if (!task.expectedStartAt || !task.expectedEndAt) return false;
+ const start = new Date(task.expectedStartAt).getTime();
+ const end = new Date(task.expectedEndAt).getTime();
+ return Number.isFinite(start) && Number.isFinite(end) && end > start;
+}
+
+export function canStartDevTask(task: Pick): boolean {
+ return !needsDevTaskClaim(task) && hasDevTaskPlan(task);
+}
+
export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());
diff --git a/apps/web/lib/test-case-workflow.test.ts b/apps/web/lib/test-case-workflow.test.ts
index e2f77dc..a2c18ad 100644
--- a/apps/web/lib/test-case-workflow.test.ts
+++ b/apps/web/lib/test-case-workflow.test.ts
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import type { TestCase } from './test-case';
+import { canStartTestCase, hasTestCasePlan, needsTestCaseClaim } from './test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';
function tc(patch: Partial = {}): TestCase {
@@ -14,6 +15,9 @@ function tc(patch: Partial = {}): TestCase {
categoryId: 'cat-test-functional',
priority: 'P2',
status: 'pending',
+ assigneeId: 'QA',
+ plannedTestAt: '2026-06-25T01:00:00.000Z',
+ plannedEndAt: '2026-06-25T02:00:00.000Z',
createdBy: 'QA',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
@@ -48,6 +52,44 @@ test('pending to running writes startedAt', () => {
assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
});
+test('AI test case without an assignee must be claimed with a plan before running', () => {
+ const draft = tc({
+ assigneeId: undefined,
+ plannedTestAt: undefined,
+ plannedEndAt: undefined,
+ aiDraft: true,
+ });
+
+ assert.equal(needsTestCaseClaim(draft), true);
+ assert.equal(hasTestCasePlan(draft), false);
+ assert.equal(canStartTestCase(draft), false);
+
+ const result = applyTestCaseTransition(draft, 'running', {
+ now: new Date('2026-06-25T01:00:00.000Z'),
+ });
+
+ assert.equal(result.ok, false);
+});
+
+test('recommended assignee test case still needs a plan before running', () => {
+ const draft = tc({
+ assigneeId: 'QA',
+ plannedTestAt: undefined,
+ plannedEndAt: undefined,
+ aiDraft: true,
+ });
+
+ assert.equal(needsTestCaseClaim(draft), false);
+ assert.equal(hasTestCasePlan(draft), false);
+ assert.equal(canStartTestCase(draft), false);
+
+ const result = applyTestCaseTransition(draft, 'running', {
+ now: new Date('2026-06-25T01:00:00.000Z'),
+ });
+
+ assert.equal(result.ok, false);
+});
+
test('running to passed writes completedAt', () => {
const result = applyTestCaseTransition(tc({
status: 'running',
diff --git a/apps/web/lib/test-case-workflow.ts b/apps/web/lib/test-case-workflow.ts
index 9244f3e..6cbdaac 100644
--- a/apps/web/lib/test-case-workflow.ts
+++ b/apps/web/lib/test-case-workflow.ts
@@ -1,5 +1,5 @@
import type { TestCase, TestCaseStatus } from './test-case';
-import { canTcTransition, getTestCaseRoundNo } from './test-case';
+import { canStartTestCase, canTcTransition, getTestCaseRoundNo } from './test-case';
export interface TestCaseWorkflowResult {
ok: boolean;
@@ -36,6 +36,10 @@ export function applyTestCaseTransition(
}
const nowIso = (options.now ?? new Date()).toISOString();
+ if (to === 'running' && !canStartTestCase(testCase)) {
+ return { ok: false, message: '开始测试前需要先领取并填写计划开始和计划结束时间' };
+ }
+
const patch: Partial = {
status: to,
aiDraft: false,
diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts
index efb7ec9..0f09549 100644
--- a/apps/web/lib/test-case.ts
+++ b/apps/web/lib/test-case.ts
@@ -23,6 +23,7 @@ export interface TestCase {
estimateHours?: number;
aiEstimateHours?: number;
plannedTestAt?: string;
+ plannedEndAt?: string;
startedAt?: string;
completedAt?: string;
executedAt?: string;
@@ -98,6 +99,7 @@ export function normalizeTestCase(testCase: Partial, index = 0): TestC
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
plannedTestAt: testCase.plannedTestAt,
+ plannedEndAt: testCase.plannedEndAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
@@ -172,6 +174,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
estimateHours: source.estimateHours,
aiEstimateHours: source.aiEstimateHours,
plannedTestAt: source.plannedTestAt,
+ plannedEndAt: source.plannedEndAt,
assigneeId: source.assigneeId,
startedAt: undefined,
completedAt: undefined,
@@ -224,6 +227,23 @@ export function getTestCaseEstimateHours(tc: TestCase): number {
return 0;
}
+export function needsTestCaseClaim(testCase: Pick): boolean {
+ return !testCase.assigneeId?.trim();
+}
+
+export function hasTestCasePlan(testCase: Pick): boolean {
+ if (!testCase.plannedTestAt || !testCase.plannedEndAt) return false;
+ const start = new Date(testCase.plannedTestAt).getTime();
+ const end = new Date(testCase.plannedEndAt).getTime();
+ return Number.isFinite(start) && Number.isFinite(end) && end > start;
+}
+
+export function canStartTestCase(
+ testCase: Pick,
+): boolean {
+ return !needsTestCaseClaim(testCase) && hasTestCasePlan(testCase);
+}
+
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
if (!tc.startedAt) return 0;
const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked';
diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts
index 2370018..42ca8af 100644
--- a/apps/web/lib/xiaobao-risk-trend.test.ts
+++ b/apps/web/lib/xiaobao-risk-trend.test.ts
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import type { Bug } from './bug';
+import { calcXiaobaoVersionRisk } from './xiaobao-risk';
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
@@ -38,3 +40,55 @@ test('buildRiskSignature changes when score and bug counts change', () => {
assert.notEqual(base, changed);
});
+
+test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
+ const now = new Date('2026-07-02T01:00:00.000Z');
+ const risk = calcXiaobaoVersionRisk({
+ version: {
+ id: 'ver-1',
+ name: 'V1.0',
+ expectedReleaseDate: '2026-07-03T10:00:00.000Z',
+ members: [{ name: 'Alice' }],
+ },
+ devTasks: [],
+ testCases: [],
+ bugs: [bug({ severity: 'major', priority: 'P2' })],
+ now,
+ });
+ const currentSignature = (risk.trend as { currentSignature?: string }).currentSignature;
+
+ assert.equal(risk.signals.openBugCount, 1);
+ assert.equal(risk.signals.criticalBugCount, 0);
+ assert.equal(currentSignature, buildRiskSignature({
+ versionId: risk.versionId,
+ date: now.toISOString().slice(0, 10),
+ riskScore: risk.riskScore,
+ riskLevel: risk.riskLevel,
+ forecastReleaseDate: risk.forecastReleaseDate,
+ openBugCount: 1,
+ failedTestCount: 0,
+ blockedCount: 0,
+ silentRiskCount: 0,
+ confidence: risk.confidence,
+ createdAt: now.toISOString(),
+ }));
+});
+
+function bug(patch: Partial = {}): Bug {
+ return {
+ id: 'bug-1',
+ bugNo: 'BUG-001',
+ versionId: 'ver-1',
+ testCaseId: 'tc-1',
+ title: 'Non-critical open bug',
+ description: 'Open but not critical.',
+ severity: 'major',
+ priority: 'P2',
+ reportedBy: 'qa-1',
+ assigneeId: 'dev-1',
+ status: 'open',
+ createdAt: '2026-07-01T09:00:00.000Z',
+ updatedAt: '2026-07-01T09:00:00.000Z',
+ ...patch,
+ };
+}
diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts
index c3249e1..ade3cd0 100644
--- a/apps/web/lib/xiaobao-risk-trend.ts
+++ b/apps/web/lib/xiaobao-risk-trend.ts
@@ -19,6 +19,7 @@ export interface RiskTrendSummary {
delta: number;
summary: string;
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
+ currentSignature?: string;
}
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
@@ -65,7 +66,10 @@ export function summarizeRiskTrendWithCurrent(
snapshots: XiaobaoRiskSnapshot[] = [],
current: XiaobaoRiskSnapshot,
): RiskTrendSummary {
- return summarizeRiskTrend([...snapshots, current]);
+ return {
+ ...summarizeRiskTrend([...snapshots, current]),
+ currentSignature: buildRiskSignature(current),
+ };
}
export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts
index 4737997..7e93b8c 100644
--- a/apps/web/lib/xiaobao-risk.ts
+++ b/apps/web/lib/xiaobao-risk.ts
@@ -204,7 +204,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
riskScore,
riskLevel,
forecastReleaseDate,
- openBugCount: signals.criticalBugCount,
+ openBugCount: signals.openBugCount,
failedTestCount: signals.failedTestCount,
blockedCount: signals.blockedCount,
silentRiskCount: signals.silentRiskCount,
diff --git a/docs/decisions.md b/docs/decisions.md
index a178cec..06db04a 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -96,8 +96,9 @@
**决策**:
- `status: todo|in_progress|testing|submitted`
- `isBlocked: boolean` + `blockReason: string` + `blockedById: string`
+- DevTask 存在阻塞时不能转为 `submitted`,必须先解除阻塞再提测。
-**理由**:阻塞和状态正交。工作台筛选 `isBlocked=true` 一键拉出所有阻塞项,跨状态。
+**理由**:阻塞和状态正交。工作台筛选 `isBlocked=true` 一键拉出所有阻塞项,跨状态;但 `submitted` 代表开发交付完成,仍必须满足“当前无阻塞”的完成条件。
## 11. 加班原因可选,且去掉"其他"
@@ -352,7 +353,7 @@
- 新建统一的工作日日期时间选择组件,创建任务时复用同一套交互。
- 内置国务院办公厅发布的 2026 年中国法定节假日和调休工作日;未知年份按周末/工作日兜底。
- 选择节假日或周末时只提示,不阻止保存;选择调休工作日时按工作日提示。
-- TestCase 增加 `plannedTestAt`,Bug 增加 `plannedFixAt`,保存为 ISO 时间戳。
+- TestCase 增加 `plannedTestAt` / `plannedEndAt`,Bug 增加 `plannedFixAt`,保存为 ISO 时间戳。
**理由**:项目排期需要贴近中国工作日,但研发和线上 Bug 可能确实安排在非工作日处理,所以系统负责提醒,最终是否保存交给用户判断。
@@ -404,3 +405,16 @@
- 没有明确角色匹配或成员匹配时,AI 不输出推荐字段,任务保持未分配,供成员后续领取或手动分配。
**理由**:负责人推荐能减少项目经理初次分配成本,但分配本身是团队执行承诺,必须由人确认。把推荐和写入分开,可以复用版本成员上下文,又避免模型幻觉姓名或越权自动派单。
+
+## 34. AI 草案领取和计划必须绑定
+
+**问题**:AI 生成的 DevTask / TestCase 如果没有负责人,团队需要先领取;如果把“待领取”和“待排期”拆成两个可见状态,会让版本详情列表出现更多标签,且无法体现“领取时就应该承诺计划”的业务动作。
+
+**决策**:
+- 版本详情开发任务和测试用例不显示“待排期”标签。
+- 无负责人时显示“待领取”,领取入口必须同时填写计划起止时间。
+- 用户采纳 AI 推荐负责人后,草案已经有负责人,不再需要领取;但开始开发/测试前仍必须补齐计划起止时间。
+- DevTask 进入 `in_progress` 前必须具备 `assigneeId`、`expectedStartAt`、`expectedEndAt`。
+- TestCase 进入 `running` 前必须具备 `assigneeId`、`plannedTestAt`、`plannedEndAt`。
+
+**理由**:领取代表成员承诺执行,计划时间代表承诺边界,二者应该在同一个动作里完成。列表层只表达“谁还没接手”,状态机层负责阻止未计划任务进入执行,页面不会被额外标签干扰。
diff --git a/docs/superpowers/plans/2026-06-29-xiaobao-warning.md b/docs/superpowers/plans/2026-06-29-xiaobao-warning.md
new file mode 100644
index 0000000..4ad0092
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-xiaobao-warning.md
@@ -0,0 +1,2394 @@
+# Xiaobao Warning Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the Xiaobao Warning page with role-based visibility, deterministic version release-risk prediction, risk trend snapshots, silent-risk detection, diary evidence, and automatic AI interpretation.
+
+**Architecture:** Keep risk judgment in pure frontend engines under `apps/web/lib/`, persist snapshots and AI interpretation caches through AppData, and call the existing NestJS AI provider layer only for structured risk interpretation. The page consumes engine outputs and stores; it does not rebuild risk rules inside components.
+
+**Tech Stack:** Next.js App Router, React client components, Zustand, NestJS, AppData JSONB, shared TypeScript types, existing AI provider abstraction, Node test runner through `pnpm --filter web test`, Jest through `pnpm --filter server test`.
+
+---
+
+## File Structure
+
+- Create `apps/web/lib/role-permission-migration.ts`: merge newly added default permissions into known preset roles when old AppData roles are loaded.
+- Create `apps/web/lib/role-permission-migration.test.ts`: unit tests for role permission migration.
+- Modify `apps/web/lib/permissions.ts`: add Xiaobao permission group and defaults.
+- Modify `apps/web/stores/useMemberStore.ts`: call role permission migration during `fetchMembers`.
+- Modify `apps/web/lib/server-data.ts`: add `xiaobao-risk-insights` and `xiaobao-risk-snapshots` keys.
+- Modify `apps/server/src/modules/data/data-keys.ts`: allow the two new AppData keys.
+- Create `apps/web/lib/xiaobao-risk.ts`: core risk types and deterministic release forecast engine.
+- Create `apps/web/lib/xiaobao-risk.test.ts`: core risk score, forecast, confidence, and trigger tests.
+- Create `apps/web/lib/xiaobao-risk-evidence.ts`: version-level work item aggregation, diary/activity evidence aggregation, and silent-risk detection.
+- Create `apps/web/lib/xiaobao-risk-evidence.test.ts`: evidence and silent-risk tests.
+- Create `apps/web/lib/xiaobao-risk-trend.ts`: snapshot trend calculation and risk signature generation.
+- Create `apps/web/lib/xiaobao-risk-trend.test.ts`: trend and signature tests.
+- Create `apps/web/stores/useXiaobaoRiskStore.ts`: load/save snapshots and AI insight caches.
+- Create `apps/web/lib/xiaobao-risk-cache.ts`: pure cache helpers and AI insight cache types.
+- Create `apps/web/lib/xiaobao-risk-ai.ts`: decide when AI interpretation is needed and call the backend.
+- Create `apps/web/lib/xiaobao-risk-ai.test.ts`: AI trigger and cache reuse tests.
+- Modify `packages/shared/src/agent.ts`: add Xiaobao risk interpretation request/response types.
+- Create `apps/server/src/modules/ai/dto/risk-interpret.dto.ts`: Nest DTO for risk interpretation.
+- Create `apps/server/src/modules/ai/prompts/risk-interpret.ts`: system prompt and tool schema.
+- Modify `apps/server/src/modules/ai/ai.service.ts`: add `interpretRisk`.
+- Modify `apps/server/src/modules/ai/ai.controller.ts`: add `POST /api/v1/ai/risk-interpret`.
+- Create or modify `apps/server/src/modules/ai/ai.service.spec.ts`: cover successful interpretation and provider failure.
+- Modify `apps/web/components/layout/Sidebar.tsx`: add Xiaobao Warning nav item above Workspace.
+- Create `apps/web/app/xiaobao-warning/page.tsx`: Xiaobao Warning page shell, data loading, filtering, automatic AI interpretation.
+- Create `apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx`: compact version risk card.
+- Create `apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx`: evidence, trend, and AI detail drawer.
+- Modify `docs/architecture.md`, `docs/decisions.md`, `docs/workflow.md`, `docs/roadmap.md`, `docs/agent-spec.md`: document finalized feature behavior.
+
+---
+
+### Task 1: Permissions And AppData Keys
+
+**Files:**
+- Create: `apps/web/lib/role-permission-migration.ts`
+- Create: `apps/web/lib/role-permission-migration.test.ts`
+- Modify: `apps/web/lib/permissions.ts`
+- Modify: `apps/web/stores/useMemberStore.ts`
+- Modify: `apps/web/lib/server-data.ts`
+- Modify: `apps/server/src/modules/data/data-keys.ts`
+
+- [ ] **Step 1: Write failing permission migration tests**
+
+Create `apps/web/lib/role-permission-migration.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { RoleItem } from './members';
+import { mergePresetRolePermissions } from './role-permission-migration';
+
+test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
+ const roles: RoleItem[] = [
+ {
+ id: 'role-pm',
+ name: '产品经理',
+ createdAt: '2024-01-01',
+ permissions: ['product:view'],
+ },
+ ];
+
+ const result = mergePresetRolePermissions(roles);
+
+ assert.equal(result.changed, true);
+ assert.ok(result.roles[0].permissions.includes('product:view'));
+ assert.ok(result.roles[0].permissions.includes('xiaobao.warning:view'));
+ assert.ok(result.roles[0].permissions.includes('xiaobao.warning:manage'));
+});
+
+test('mergePresetRolePermissions keeps custom role permissions unchanged', () => {
+ const roles: RoleItem[] = [
+ {
+ id: 'role-custom',
+ name: '自定义角色',
+ createdAt: '2024-01-01',
+ permissions: ['version:view'],
+ },
+ ];
+
+ const result = mergePresetRolePermissions(roles);
+
+ assert.equal(result.changed, false);
+ assert.deepEqual(result.roles[0].permissions, ['version:view']);
+});
+```
+
+- [ ] **Step 2: Run the permission migration test to verify it fails**
+
+Run:
+
+```bash
+pnpm --filter web test -- role-permission-migration
+```
+
+Expected: FAIL because `apps/web/lib/role-permission-migration.ts` does not exist.
+
+- [ ] **Step 3: Implement permission constants and migration helper**
+
+Modify `apps/web/lib/permissions.ts` by adding Xiaobao group before member/role groups:
+
+```ts
+ {
+ module: 'xiaobao.warning',
+ moduleLabel: '小宝预警',
+ category: 'main',
+ actions: [
+ { action: 'view', label: '查看', permission: 'xiaobao.warning:view' },
+ { action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' },
+ ],
+ },
+```
+
+Add defaults in `DEFAULT_ROLE_PERMISSIONS`:
+
+```ts
+ 'role-pm': [
+ ...std4('product').map((a) => a.permission),
+ ...std4('project').map((a) => a.permission),
+ ...std4('version').map((a) => a.permission),
+ ...std4('requirement').map((a) => a.permission),
+ 'xiaobao.warning:view',
+ 'xiaobao.warning:manage',
+ 'version.req:view', 'version.req:manage',
+ 'version.product_plan:view', 'version.product_plan:manage',
+ 'overtime:view', 'member:view', 'role:view',
+ 'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
+ 'version.testcase:view', 'version.bug:view',
+ ],
+ 'role-dev': [
+ ...VIEW_ONLY_BASE,
+ 'xiaobao.warning:view',
+ 'version.devtask:view', 'version.devtask:manage',
+ 'version.bug:view', 'version.bug:edit',
+ 'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
+ 'overtime:view', 'overtime:create',
+ ],
+ 'role-test': [
+ ...VIEW_ONLY_BASE,
+ 'xiaobao.warning:view',
+ 'version.testcase:view', 'version.testcase:manage',
+ 'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
+ 'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
+ 'overtime:view', 'overtime:create',
+ ],
+ 'role-design': [
+ ...VIEW_ONLY_BASE,
+ 'xiaobao.warning:view',
+ 'version.ui_plan:view', 'version.ui_plan:manage',
+ 'version.research:view', 'version.product_plan:view', 'version.devtask:view',
+ 'version.testcase:view', 'version.bug:view',
+ 'overtime:view', 'overtime:create',
+ ],
+```
+
+Create `apps/web/lib/role-permission-migration.ts`:
+
+```ts
+import type { RoleItem } from './members';
+import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
+
+export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
+ let changed = false;
+ const next = roles.map((role) => {
+ const defaults = DEFAULT_ROLE_PERMISSIONS[role.id];
+ if (!defaults || role.permissions.includes('*')) return role;
+ const merged = Array.from(new Set([...role.permissions, ...defaults]));
+ if (merged.length === role.permissions.length) return role;
+ changed = true;
+ return { ...role, permissions: merged };
+ });
+ return { roles: next, changed };
+}
+```
+
+- [ ] **Step 4: Wire migration into member store**
+
+Modify `apps/web/stores/useMemberStore.ts` imports:
+
+```ts
+import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
+```
+
+Inside `fetchMembers`, after `migratedRoles` is built and before system admin role protection, add:
+
+```ts
+ const permissionMerged = mergePresetRolePermissions(migratedRoles);
+ const rolesWithNewDefaults = permissionMerged.roles;
+ const adminRole = PRESET_ROLES.find((r) => r.id === SYSTEM_ADMIN_ROLE_ID)!;
+ const roles = rolesWithNewDefaults.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
+ ? rolesWithNewDefaults.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
+ : [adminRole, ...rolesWithNewDefaults];
+ const rolesChanged =
+ permissionMerged.changed ||
+ roles.length !== rolesWithNewDefaults.length ||
+ roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
+```
+
+Remove the old local `adminRole`, `roles`, and `rolesChanged` declarations to avoid duplicate identifiers.
+
+- [ ] **Step 5: Add AppData keys**
+
+Modify `apps/web/lib/server-data.ts`:
+
+```ts
+ | 'work-activities'
+ | 'xiaobao-risk-insights'
+ | 'xiaobao-risk-snapshots'
+ | 'overtime';
+```
+
+Modify `apps/server/src/modules/data/data-keys.ts`:
+
+```ts
+ 'work-activities',
+ 'xiaobao-risk-insights',
+ 'xiaobao-risk-snapshots',
+ 'overtime',
+```
+
+- [ ] **Step 6: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- role-permission-migration
+pnpm --filter web type-check
+pnpm --filter server test -- data.service
+```
+
+Expected: all commands PASS.
+
+Commit:
+
+```bash
+git add apps/web/lib/permissions.ts apps/web/lib/role-permission-migration.ts apps/web/lib/role-permission-migration.test.ts apps/web/stores/useMemberStore.ts apps/web/lib/server-data.ts apps/server/src/modules/data/data-keys.ts
+git commit -m "feat(小宝预警): 增加权限与数据键"
+```
+
+---
+
+### Task 2: Core Risk Forecast Engine
+
+**Files:**
+- Create: `apps/web/lib/xiaobao-risk.ts`
+- Create: `apps/web/lib/xiaobao-risk.test.ts`
+- Create: `apps/web/lib/xiaobao-risk-trend.ts` (minimal `XiaobaoRiskSnapshot` type stub; Task 3 expands it)
+- Create: `apps/web/lib/xiaobao-risk-evidence.ts` (minimal `VersionDailyEvidence` type stub; Task 3 expands it)
+
+- [ ] **Step 1: Write failing risk engine tests**
+
+Create `apps/web/lib/xiaobao-risk.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { calcXiaobaoVersionRisk } from './xiaobao-risk';
+import type { DevTask } from './dev-task';
+import type { TestCase } from './test-case';
+import type { Bug } from './bug';
+
+function version(patch: any = {}) {
+ return {
+ id: 'ver-1',
+ name: 'V1.0',
+ status: 'developing',
+ productName: 'FTB',
+ projectName: '项目管理',
+ expectedReleaseDate: '2026-07-03T10:00:00.000Z',
+ members: [{ name: '张三', role: 'frontend' }],
+ ...patch,
+ };
+}
+
+function devTask(patch: Partial = {}): DevTask {
+ return {
+ id: 'dev-1',
+ taskNo: 'DEV-001',
+ requirementId: 'req-1',
+ title: '开发任务',
+ categoryId: 'cat-frontend',
+ assigneeId: '张三',
+ priority: 'P1',
+ expectedStartAt: '2026-07-01T01:00:00.000Z',
+ expectedEndAt: '2026-07-01T09:00:00.000Z',
+ estimateHours: 8,
+ status: 'todo',
+ isBlocked: false,
+ createdBy: '张三',
+ createdAt: '2026-07-01T01:00:00.000Z',
+ updatedAt: '2026-07-01T01:00:00.000Z',
+ ...patch,
+ };
+}
+
+function testCase(patch: Partial = {}): TestCase {
+ return {
+ id: 'tc-1',
+ caseNo: 'TC-001',
+ versionId: 'ver-1',
+ title: '测试用例',
+ categoryId: 'cat-test',
+ priority: 'P1',
+ status: 'pending',
+ estimateHours: 4,
+ createdBy: '李四',
+ createdAt: '2026-07-01T01:00:00.000Z',
+ updatedAt: '2026-07-01T01:00:00.000Z',
+ ...patch,
+ };
+}
+
+function bug(patch: Partial = {}): Bug {
+ return {
+ id: 'bug-1',
+ bugNo: 'BUG-001',
+ versionId: 'ver-1',
+ testCaseId: 'tc-1',
+ title: 'P1 Bug',
+ description: '阻断发版',
+ severity: 'major',
+ priority: 'P1',
+ reportedBy: '李四',
+ assigneeId: '张三',
+ status: 'open',
+ estimateHours: 4,
+ createdAt: '2026-07-02T01:00:00.000Z',
+ updatedAt: '2026-07-02T01:00:00.000Z',
+ ...patch,
+ };
+}
+
+test('calcXiaobaoVersionRisk predicts delay when remaining work exceeds release date', () => {
+ const risk = calcXiaobaoVersionRisk({
+ version: version(),
+ plans: [],
+ requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
+ devTasks: [devTask({ estimateHours: 40 })],
+ testCases: [testCase({ estimateHours: 16 })],
+ bugs: [],
+ dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
+ snapshots: [],
+ now: new Date('2026-07-02T01:00:00.000Z'),
+ });
+
+ assert.equal(risk.riskLevel, 'likely_delayed');
+ assert.ok(risk.riskScore >= 70);
+ assert.ok(risk.delayDays > 0);
+ assert.ok(risk.reasons.some((r) => r.key === 'remaining_work'));
+});
+
+test('calcXiaobaoVersionRisk marks blocked when critical signals exist', () => {
+ const risk = calcXiaobaoVersionRisk({
+ version: version(),
+ plans: [],
+ requirements: [{ id: 'req-1', versionId: 'ver-1' } as any],
+ devTasks: [devTask({ isBlocked: true, blockReason: '接口未联调' })],
+ testCases: [testCase({ status: 'failed' })],
+ bugs: [bug({ severity: 'critical' })],
+ dailyEvidence: { todayDeliveries: [], todayProgress: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0 },
+ snapshots: [],
+ now: new Date('2026-07-02T01:00:00.000Z'),
+ });
+
+ assert.equal(risk.riskLevel, 'blocked');
+ assert.ok(risk.reasons.some((r) => r.key === 'critical_bug'));
+ assert.ok(risk.reasons.some((r) => r.key === 'blocked_work'));
+});
+```
+
+- [ ] **Step 2: Run the risk engine test to verify it fails**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk
+```
+
+Expected: FAIL because `apps/web/lib/xiaobao-risk.ts` does not exist.
+
+- [ ] **Step 3: Implement the core risk engine**
+
+Create `apps/web/lib/xiaobao-risk-trend.ts` as a type stub:
+
+```ts
+import type { XiaobaoRiskLevel } from './xiaobao-risk';
+
+export interface XiaobaoRiskSnapshot {
+ versionId: string;
+ date: string;
+ riskScore: number;
+ riskLevel: XiaobaoRiskLevel;
+ forecastReleaseDate?: string;
+ openBugCount: number;
+ failedTestCount: number;
+ blockedCount: number;
+ silentRiskCount: number;
+ confidence: number;
+ createdAt: string;
+}
+```
+
+Create `apps/web/lib/xiaobao-risk-evidence.ts` as a type stub:
+
+```ts
+import type { SilentRisk } from './xiaobao-risk';
+
+export interface EvidenceItem {
+ id: string;
+ title: string;
+ summary: string;
+ occurredAt: string;
+ actorId?: string;
+}
+
+export interface VersionDailyEvidence {
+ todayDeliveries: EvidenceItem[];
+ todayProgress: EvidenceItem[];
+ todayRisks: EvidenceItem[];
+ progressNotes: EvidenceItem[];
+ needsProgressItems: EvidenceItem[];
+ recentActivityCount: number;
+ lastActivityAt?: string;
+ silentRisks?: SilentRisk[];
+}
+```
+
+Create `apps/web/lib/xiaobao-risk.ts`:
+
+```ts
+import type { VersionPlan } from './version-plan';
+import type { DevTask } from './dev-task';
+import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
+import type { TestCase } from './test-case';
+import { getTestCaseEstimateHours } from './test-case';
+import type { Bug } from './bug';
+import { addWorkHours } from './work-hours';
+import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
+import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
+
+export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
+export type XiaobaoConfidenceLevel = 'low' | 'medium' | 'high';
+
+export interface XiaobaoVersionRef {
+ id: string;
+ name: string;
+ status?: string;
+ productName?: string;
+ projectName?: string;
+ expectedReleaseDate?: string | null;
+ members?: Array<{ name: string; role: string }>;
+}
+
+export interface RiskReason {
+ key: string;
+ label: string;
+ severity: 'low' | 'medium' | 'high' | 'critical';
+ detail: string;
+}
+
+export interface SilentRisk {
+ key: 'no_update' | 'no_report' | 'no_activity' | 'unhandled';
+ days: number;
+ detail: string;
+}
+
+export interface XiaobaoRiskSignals {
+ unfinishedCount: number;
+ openBugCount: number;
+ criticalBugCount: number;
+ failedTestCount: number;
+ blockedCount: number;
+ silentRiskCount: number;
+ daysToExpectedRelease?: number;
+}
+
+export interface XiaobaoVersionRisk {
+ versionId: string;
+ versionName: string;
+ productName?: string;
+ projectName?: string;
+ riskScore: number;
+ riskLevel: XiaobaoRiskLevel;
+ expectedReleaseDate?: string;
+ forecastReleaseDate?: string;
+ delayDays: number;
+ confidence: number;
+ confidenceLevel: XiaobaoConfidenceLevel;
+ reasons: RiskReason[];
+ silentRisks: SilentRisk[];
+ signals: XiaobaoRiskSignals;
+ dailyEvidence: VersionDailyEvidence;
+ trend: {
+ direction: 'up' | 'down' | 'stable' | 'unknown';
+ delta: number;
+ summary: string;
+ };
+}
+
+export interface CalcXiaobaoVersionRiskInput {
+ version: XiaobaoVersionRef;
+ plans: VersionPlan[];
+ requirements: Array<{ id: string; versionId?: string | null }>;
+ devTasks: DevTask[];
+ testCases: TestCase[];
+ bugs: Bug[];
+ dailyEvidence: VersionDailyEvidence;
+ snapshots: XiaobaoRiskSnapshot[];
+ now?: Date;
+}
+
+const BUG_FALLBACK_HOURS: Record = {
+ critical: 8,
+ major: 4,
+ minor: 2,
+ trivial: 1,
+};
+
+export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): XiaobaoVersionRisk {
+ const now = input.now ?? new Date();
+ const reasons: RiskReason[] = [];
+ const silentRisks = input.dailyEvidence.silentRisks ?? [];
+ const remainingHours = calcRemainingHours(input.devTasks, input.testCases, input.bugs);
+ const forecastReleaseDate = remainingHours > 0 ? addWorkHours(now.toISOString(), remainingHours) : now.toISOString();
+ const expected = input.version.expectedReleaseDate || undefined;
+ const delayDays = expected ? calcDelayDays(forecastReleaseDate, expected) : 0;
+
+ const openBugs = input.bugs.filter((bug) => bug.status !== 'closed' && bug.status !== 'rejected');
+ const criticalBugs = openBugs.filter((bug) => bug.severity === 'critical' || bug.priority === 'P0' || bug.priority === 'P1');
+ const blockedDevTasks = input.devTasks.filter((task) => task.isBlocked);
+ const failedOrBlockedTests = input.testCases.filter((testCase) => testCase.status === 'failed' || testCase.status === 'blocked');
+ const unfinishedDevTasks = input.devTasks.filter((task) => task.status !== 'submitted');
+ const unfinishedTestCases = input.testCases.filter((testCase) => testCase.status !== 'passed');
+ const signals: XiaobaoRiskSignals = {
+ unfinishedCount: unfinishedDevTasks.length + unfinishedTestCases.length + openBugs.length,
+ openBugCount: openBugs.length,
+ criticalBugCount: criticalBugs.length,
+ failedTestCount: failedOrBlockedTests.length,
+ blockedCount: blockedDevTasks.length + failedOrBlockedTests.length,
+ silentRiskCount: silentRisks.length,
+ daysToExpectedRelease: expected ? calcDaysUntil(expected, now) : undefined,
+ };
+
+ if (remainingHours > 0) {
+ reasons.push({ key: 'remaining_work', label: '剩余工作量', severity: delayDays > 0 ? 'high' : 'medium', detail: `剩余约 ${roundTenth(remainingHours)}h` });
+ }
+ if (criticalBugs.length > 0) {
+ reasons.push({ key: 'critical_bug', label: '关键 Bug 未关闭', severity: 'critical', detail: `${criticalBugs.length} 个 P1/P0 或致命 Bug 未关闭` });
+ }
+ if (blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0) {
+ reasons.push({ key: 'blocked_work', label: '阻塞项', severity: 'critical', detail: `${blockedDevTasks.length + failedOrBlockedTests.length} 个开发/测试阻塞或失败项` });
+ }
+ if (silentRisks.length > 0) {
+ reasons.push({ key: 'silent_risk', label: '静默风险', severity: 'medium', detail: `${silentRisks.length} 个静默风险` });
+ }
+
+ const riskScore = clampRiskScore(
+ 15 +
+ Math.min(35, remainingHours * 1.2) +
+ Math.max(0, delayDays) * 8 +
+ criticalBugs.length * 18 +
+ (blockedDevTasks.length + failedOrBlockedTests.length) * 12 +
+ silentRisks.length * 8,
+ );
+ const riskLevel = calcRiskLevel(riskScore, {
+ hasCriticalBug: criticalBugs.length > 0,
+ hasBlocking: blockedDevTasks.length > 0 || failedOrBlockedTests.length > 0,
+ delayDays,
+ });
+ const confidence = calcConfidence(input, remainingHours);
+ const trend = calcTrend(input.snapshots, riskScore);
+
+ return {
+ versionId: input.version.id,
+ versionName: input.version.name,
+ productName: input.version.productName,
+ projectName: input.version.projectName,
+ riskScore,
+ riskLevel,
+ expectedReleaseDate: expected,
+ forecastReleaseDate,
+ delayDays,
+ confidence,
+ confidenceLevel: confidence >= 75 ? 'high' : confidence >= 45 ? 'medium' : 'low',
+ reasons,
+ silentRisks,
+ signals,
+ dailyEvidence: input.dailyEvidence,
+ trend,
+ };
+}
+
+function calcRemainingHours(devTasks: DevTask[], testCases: TestCase[], bugs: Bug[]): number {
+ const devHours = devTasks.reduce((sum, task) => sum + getEstimateHours(task) * ((100 - STATUS_PROGRESS[task.status]) / 100), 0);
+ const testHours = testCases.reduce((sum, testCase) => {
+ if (testCase.status === 'passed') return sum;
+ return sum + getTestCaseEstimateHours(testCase);
+ }, 0);
+ const bugHours = bugs.reduce((sum, bug) => {
+ if (bug.status === 'closed' || bug.status === 'rejected') return sum;
+ return sum + (bug.estimateHours ?? bug.aiEstimateHours ?? BUG_FALLBACK_HOURS[bug.severity]);
+ }, 0);
+ return roundTenth(devHours + testHours + bugHours);
+}
+
+function calcDelayDays(forecastIso: string, expectedIso: string): number {
+ const forecast = new Date(forecastIso);
+ const expected = new Date(expectedIso);
+ forecast.setHours(0, 0, 0, 0);
+ expected.setHours(0, 0, 0, 0);
+ return Math.max(0, Math.ceil((forecast.getTime() - expected.getTime()) / 86400000));
+}
+
+function calcDaysUntil(targetIso: string, now: Date): number {
+ const target = new Date(targetIso);
+ const current = new Date(now);
+ target.setHours(0, 0, 0, 0);
+ current.setHours(0, 0, 0, 0);
+ return Math.ceil((target.getTime() - current.getTime()) / 86400000);
+}
+
+function calcRiskLevel(score: number, flags: { hasCriticalBug: boolean; hasBlocking: boolean; delayDays: number }): XiaobaoRiskLevel {
+ if (flags.hasCriticalBug || flags.hasBlocking) return 'blocked';
+ if (flags.delayDays > 0 || score >= 75) return 'likely_delayed';
+ if (score >= 55) return 'at_risk';
+ if (score >= 30) return 'attention';
+ return 'on_track';
+}
+
+function calcConfidence(input: CalcXiaobaoVersionRiskInput, remainingHours: number): number {
+ let confidence = 100;
+ if (!input.version.expectedReleaseDate) confidence -= 30;
+ if (remainingHours === 0 && input.devTasks.some((task) => !task.estimateHours && !task.aiEstimateHours)) confidence -= 15;
+ if (input.testCases.length === 0) confidence -= 15;
+ if (!input.version.members || input.version.members.length === 0) confidence -= 10;
+ if (input.dailyEvidence.recentActivityCount === 0) confidence -= 10;
+ if (input.snapshots.length < 2) confidence -= 10;
+ return Math.max(0, Math.min(100, Math.round(confidence)));
+}
+
+function calcTrend(snapshots: XiaobaoRiskSnapshot[], riskScore: number): XiaobaoVersionRisk['trend'] {
+ const last = snapshots.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
+ if (!last) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
+ const delta = riskScore - last.riskScore;
+ if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
+ if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
+ return { direction: 'stable', delta, summary: '风险基本稳定' };
+}
+
+function clampRiskScore(value: number): number {
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function roundTenth(value: number): number {
+ return Math.round(value * 10) / 10;
+}
+```
+
+- [ ] **Step 4: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-evidence.ts
+git commit -m "feat(小宝预警): 增加版本风险预测引擎"
+```
+
+---
+
+### Task 3: Diary Evidence, Silent Risk, Trend, And Signatures
+
+**Files:**
+- Modify: `apps/web/lib/xiaobao-risk-evidence.ts`
+- Create: `apps/web/lib/xiaobao-risk-evidence.test.ts`
+- Modify: `apps/web/lib/xiaobao-risk-trend.ts`
+- Create: `apps/web/lib/xiaobao-risk-trend.test.ts`
+- Modify: `apps/web/lib/xiaobao-risk.ts`
+
+- [ ] **Step 1: Write failing evidence and trend tests**
+
+Create `apps/web/lib/xiaobao-risk-evidence.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence';
+
+test('buildVersionDailyEvidence groups version activity and detects no activity', () => {
+ const evidence = buildVersionDailyEvidence({
+ versionId: 'ver-1',
+ workItems: [{ id: 'dev-1', versionId: 'ver-1', title: '开发', status: 'in_progress', completed: false, type: 'devTask', productName: 'FTB', projectName: '项目', versionName: 'V1', raw: { updatedAt: '2026-06-24T01:00:00.000Z' } } as any],
+ activities: [],
+ worklogs: [],
+ now: new Date('2026-06-29T01:00:00.000Z'),
+ });
+
+ assert.equal(evidence.recentActivityCount, 0);
+ assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_activity'));
+ assert.ok(evidence.silentRisks.some((risk) => risk.key === 'no_update'));
+});
+
+test('buildXiaobaoWorkItems keeps all version work instead of filtering by current user', () => {
+ const items = buildXiaobaoWorkItems({
+ plans: [{ id: 'plan-1', versionId: 'ver-1', type: 'product', title: 'Product plan', status: 'in_progress', owner: 'pm' } as any],
+ devTasks: [{ id: 'dev-1', requirementId: 'req-1', title: 'Develop feature', status: 'todo', priority: 'P1' } as any],
+ testCases: [{ id: 'tc-1', versionId: 'ver-1', title: 'Run test', status: 'pending', priority: 'P1' } as any],
+ bugs: [{ id: 'bug-1', versionId: 'ver-1', title: 'Fix bug', status: 'open', priority: 'P1', severity: 'major' } as any],
+ versionMap: new Map([['ver-1', { id: 'ver-1', name: 'V1', productName: 'FTB', projectName: 'PM' }]]),
+ requirementVersionMap: new Map([['req-1', 'ver-1']]),
+ });
+
+ assert.deepEqual(items.map((item) => item.id).sort(), ['bug-1', 'dev-1', 'plan-1', 'tc-1']);
+});
+```
+
+Create `apps/web/lib/xiaobao-risk-trend.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
+
+test('summarizeRiskTrend detects continuous rising risk', () => {
+ const trend = summarizeRiskTrend([
+ { versionId: 'ver-1', date: '2026-06-27', riskScore: 38, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-27T01:00:00.000Z' },
+ { versionId: 'ver-1', date: '2026-06-28', riskScore: 52, riskLevel: 'attention', openBugCount: 1, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 75, createdAt: '2026-06-28T01:00:00.000Z' },
+ { versionId: 'ver-1', date: '2026-06-29', riskScore: 71, riskLevel: 'at_risk', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 70, createdAt: '2026-06-29T01:00:00.000Z' },
+ ]);
+
+ assert.equal(trend.direction, 'up');
+ assert.equal(trend.summary, '风险连续 3 次上升');
+});
+
+test('buildRiskSignature changes when score and bug counts change', () => {
+ const a = buildRiskSignature({ versionId: 'ver-1', riskScore: 35, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
+ const b = buildRiskSignature({ versionId: 'ver-1', riskScore: 68, riskLevel: 'attention', forecastReleaseDate: '2026-07-01', openBugCount: 3, failedTestCount: 1, blockedCount: 0, silentRiskCount: 0, confidence: 80 });
+ assert.notEqual(a, b);
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-evidence
+pnpm --filter web test -- xiaobao-risk-trend
+```
+
+Expected: FAIL because `buildVersionDailyEvidence`, `buildXiaobaoWorkItems`, `summarizeRiskTrend`, and `buildRiskSignature` are not implemented yet.
+
+- [ ] **Step 3: Implement evidence aggregation**
+
+Replace the type stub in `apps/web/lib/xiaobao-risk-evidence.ts` with:
+
+```ts
+import type { TaskWorklog } from './task-worklog';
+import type { VersionPlan } from './version-plan';
+import type { DevTask } from './dev-task';
+import type { TestCase } from './test-case';
+import type { Bug } from './bug';
+import type { WorkActivity } from './work-activity';
+import type { WorkItem } from './workspace-engine';
+import type { SilentRisk } from './xiaobao-risk';
+
+export interface EvidenceItem {
+ id: string;
+ title: string;
+ summary: string;
+ occurredAt: string;
+ actorId?: string;
+}
+
+export interface VersionDailyEvidence {
+ todayDeliveries: EvidenceItem[];
+ todayProgress: EvidenceItem[];
+ todayRisks: EvidenceItem[];
+ progressNotes: EvidenceItem[];
+ needsProgressItems: EvidenceItem[];
+ recentActivityCount: number;
+ lastActivityAt?: string;
+ silentRisks: SilentRisk[];
+}
+
+export interface BuildVersionDailyEvidenceInput {
+ versionId: string;
+ workItems: WorkItem[];
+ activities: WorkActivity[];
+ worklogs: TaskWorklog[];
+ now?: Date;
+}
+
+export interface XiaobaoVersionContext {
+ id: string;
+ name: string;
+ productName: string;
+ projectName: string;
+}
+
+export interface BuildXiaobaoWorkItemsInput {
+ plans: VersionPlan[];
+ devTasks: DevTask[];
+ testCases: TestCase[];
+ bugs: Bug[];
+ versionMap: Map;
+ requirementVersionMap: Map;
+}
+
+const MS_PER_DAY = 86400000;
+
+export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
+ const items: WorkItem[] = [];
+
+ input.plans.forEach((plan) => {
+ const version = input.versionMap.get(plan.versionId);
+ items.push({
+ id: plan.id,
+ type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
+ title: plan.title,
+ status: plan.status,
+ completed: plan.status === 'completed',
+ productName: version?.productName ?? '-',
+ projectName: version?.projectName ?? '-',
+ versionName: version?.name ?? '-',
+ versionId: plan.versionId,
+ extra: { startTime: plan.startTime, endTime: plan.endTime },
+ raw: plan,
+ });
+ });
+
+ input.devTasks.forEach((task) => {
+ const versionId = input.requirementVersionMap.get(task.requirementId) ?? '';
+ const version = input.versionMap.get(versionId);
+ items.push({
+ id: task.id,
+ type: 'devTask',
+ title: task.title,
+ status: task.status,
+ completed: task.status === 'submitted',
+ productName: version?.productName ?? '-',
+ projectName: version?.projectName ?? '-',
+ versionName: version?.name ?? '-',
+ versionId,
+ priority: task.priority,
+ extra: { taskNo: task.taskNo, expectedStartAt: task.expectedStartAt, expectedEndAt: task.expectedEndAt },
+ raw: task,
+ });
+ });
+
+ input.testCases.forEach((testCase) => {
+ const version = input.versionMap.get(testCase.versionId);
+ items.push({
+ id: testCase.id,
+ type: 'testCase',
+ title: testCase.title,
+ status: testCase.status,
+ completed: testCase.status === 'passed',
+ productName: version?.productName ?? '-',
+ projectName: version?.projectName ?? '-',
+ versionName: version?.name ?? '-',
+ versionId: testCase.versionId,
+ priority: testCase.priority,
+ extra: { caseNo: testCase.caseNo },
+ raw: testCase,
+ });
+ });
+
+ input.bugs.forEach((bug) => {
+ const version = input.versionMap.get(bug.versionId);
+ items.push({
+ id: bug.id,
+ type: 'bug',
+ title: bug.title,
+ status: bug.status,
+ completed: bug.status === 'closed' || bug.status === 'rejected',
+ productName: version?.productName ?? '-',
+ projectName: version?.projectName ?? '-',
+ versionName: version?.name ?? '-',
+ versionId: bug.versionId,
+ priority: bug.priority,
+ extra: { bugNo: bug.bugNo, severity: bug.severity },
+ raw: bug,
+ });
+ });
+
+ return items;
+}
+
+export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
+ const now = input.now ?? new Date();
+ const today = toDateKey(now);
+ const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
+ const sourceIds = new Set(versionItems.map((item) => item.id));
+ const activities = input.activities.filter((activity) => sourceIds.has(activity.sourceId));
+ const worklogs = input.worklogs.filter((worklog) => sourceIds.has(worklog.taskId));
+ const recentActivities = activities.filter((activity) => daysBetween(activity.occurredAt, now) <= 3);
+ const todayActivities = activities.filter((activity) => activity.date === today);
+
+ const evidence: VersionDailyEvidence = {
+ todayDeliveries: toEvidence(todayActivities.filter((activity) => activity.category === 'delivery')),
+ todayProgress: toEvidence(todayActivities.filter((activity) => activity.category === 'progress')),
+ todayRisks: toEvidence(todayActivities.filter((activity) => activity.category === 'risk')),
+ progressNotes: [
+ ...toEvidence(todayActivities.filter((activity) => activity.category === 'note')),
+ ...worklogs.filter((worklog) => worklog.date === today).map((worklog) => ({
+ id: worklog.id,
+ title: '进展说明',
+ summary: worklog.workContent,
+ occurredAt: worklog.createdAt,
+ actorId: worklog.userId,
+ })),
+ ],
+ needsProgressItems: [],
+ recentActivityCount: recentActivities.length,
+ lastActivityAt: activities.map((activity) => activity.occurredAt).sort().reverse()[0],
+ silentRisks: [],
+ };
+
+ evidence.needsProgressItems = versionItems
+ .filter((item) => !item.completed && ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(item.status))
+ .filter((item) => daysBetween(getUpdatedAt(item), now) >= 3)
+ .map((item) => ({ id: item.id, title: item.title, summary: `已 ${daysBetween(getUpdatedAt(item), now)} 天无人处理`, occurredAt: getUpdatedAt(item) }));
+
+ const noActivityDays = evidence.lastActivityAt ? daysBetween(evidence.lastActivityAt, now) : 999;
+ const lastWorklogAt = worklogs.map((worklog) => worklog.createdAt).sort().reverse()[0];
+ const noReportDays = lastWorklogAt ? daysBetween(lastWorklogAt, now) : 999;
+ const maxNoUpdateDays = Math.max(0, ...versionItems.map((item) => daysBetween(getUpdatedAt(item), now)));
+
+ if (maxNoUpdateDays >= 5) evidence.silentRisks.push({ key: 'no_update', days: maxNoUpdateDays, detail: `${maxNoUpdateDays} 天无更新` });
+ if (noReportDays >= 8) evidence.silentRisks.push({ key: 'no_report', days: noReportDays, detail: `${noReportDays} 天无日报` });
+ if (noActivityDays >= 4) evidence.silentRisks.push({ key: 'no_activity', days: noActivityDays, detail: `${noActivityDays} 天无活动` });
+ if (evidence.needsProgressItems.length > 0) evidence.silentRisks.push({ key: 'unhandled', days: 3, detail: `${evidence.needsProgressItems.length} 项超过 3 天无人处理` });
+
+ return evidence;
+}
+
+function toEvidence(activities: WorkActivity[]): EvidenceItem[] {
+ return activities.map((activity) => ({
+ id: activity.id,
+ title: activity.title,
+ summary: activity.summary,
+ occurredAt: activity.occurredAt,
+ actorId: activity.actorId,
+ }));
+}
+
+function getUpdatedAt(item: WorkItem): string {
+ const raw = item.raw as Record;
+ return typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date(0).toISOString();
+}
+
+function daysBetween(iso: string | undefined, now: Date): number {
+ if (!iso) return 999;
+ const value = new Date(iso).getTime();
+ if (!Number.isFinite(value)) return 999;
+ return Math.floor((now.getTime() - value) / MS_PER_DAY);
+}
+
+function toDateKey(date: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+```
+
+- [ ] **Step 4: Implement trend and signatures**
+
+Replace the type stub in `apps/web/lib/xiaobao-risk-trend.ts` with:
+
+```ts
+import type { XiaobaoRiskLevel } from './xiaobao-risk';
+
+export interface XiaobaoRiskSnapshot {
+ versionId: string;
+ date: string;
+ riskScore: number;
+ riskLevel: XiaobaoRiskLevel;
+ forecastReleaseDate?: string;
+ openBugCount: number;
+ failedTestCount: number;
+ blockedCount: number;
+ silentRiskCount: number;
+ confidence: number;
+ createdAt: string;
+}
+
+export interface RiskTrendSummary {
+ direction: 'up' | 'down' | 'stable' | 'unknown';
+ delta: number;
+ summary: string;
+}
+
+export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
+ const ordered = snapshots.slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+ if (ordered.length < 2) return { direction: 'unknown', delta: 0, summary: '暂无趋势' };
+ const last = ordered[ordered.length - 1];
+ const first = ordered[Math.max(0, ordered.length - 3)];
+ const delta = last.riskScore - first.riskScore;
+ const recent = ordered.slice(-3);
+ const rising = recent.length === 3 && recent[0].riskScore < recent[1].riskScore && recent[1].riskScore < recent[2].riskScore;
+ const falling = recent.length === 3 && recent[0].riskScore > recent[1].riskScore && recent[1].riskScore > recent[2].riskScore;
+ if (rising) return { direction: 'up', delta, summary: '风险连续 3 次上升' };
+ if (falling) return { direction: 'down', delta, summary: '风险连续 3 次下降' };
+ if (delta >= 10) return { direction: 'up', delta, summary: `风险上升 ${delta} 分` };
+ if (delta <= -10) return { direction: 'down', delta, summary: `风险下降 ${Math.abs(delta)} 分` };
+ return { direction: 'stable', delta, summary: '风险基本稳定' };
+}
+
+export function summarizeRiskTrendWithCurrent(snapshots: XiaobaoRiskSnapshot[], current: XiaobaoRiskSnapshot): RiskTrendSummary {
+ return summarizeRiskTrend([...snapshots, current]);
+}
+
+export function buildRiskSignature(input: {
+ versionId: string;
+ riskScore: number;
+ riskLevel: XiaobaoRiskLevel;
+ forecastReleaseDate?: string;
+ openBugCount: number;
+ failedTestCount: number;
+ blockedCount: number;
+ silentRiskCount: number;
+ confidence: number;
+}): string {
+ return [
+ input.versionId,
+ input.riskScore,
+ input.riskLevel,
+ input.forecastReleaseDate ?? '',
+ input.openBugCount,
+ input.failedTestCount,
+ input.blockedCount,
+ input.silentRiskCount,
+ input.confidence,
+ ].join('|');
+}
+```
+
+- [ ] **Step 5: Wire trend types into core engine**
+
+Modify `apps/web/lib/xiaobao-risk.ts` imports and replace the local trend summary logic with `summarizeRiskTrendWithCurrent`:
+
+```ts
+import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
+```
+
+Replace the trend calculation in `calcXiaobaoVersionRisk` after `confidence` is calculated:
+
+```ts
+ const trend = summarizeRiskTrendWithCurrent(input.snapshots, {
+ versionId: input.version.id,
+ date: now.toISOString().slice(0, 10),
+ riskScore,
+ riskLevel,
+ forecastReleaseDate,
+ openBugCount: signals.criticalBugCount,
+ failedTestCount: signals.failedTestCount,
+ blockedCount: signals.blockedCount,
+ silentRiskCount: signals.silentRiskCount,
+ confidence,
+ createdAt: now.toISOString(),
+ });
+```
+
+Then delete the old local `calcTrend` helper from `apps/web/lib/xiaobao-risk.ts`.
+
+- [ ] **Step 6: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-evidence
+pnpm --filter web test -- xiaobao-risk-trend
+pnpm --filter web test -- xiaobao-risk
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add apps/web/lib/xiaobao-risk-evidence.ts apps/web/lib/xiaobao-risk-evidence.test.ts apps/web/lib/xiaobao-risk-trend.ts apps/web/lib/xiaobao-risk-trend.test.ts apps/web/lib/xiaobao-risk.ts
+git commit -m "feat(小宝预警): 增加日报证据与风险趋势"
+```
+
+---
+
+### Task 4: Snapshots And Insight Cache Store
+
+**Files:**
+- Create: `apps/web/stores/useXiaobaoRiskStore.ts`
+- Create: `apps/web/lib/xiaobao-risk-cache.ts`
+- Create: `apps/web/lib/xiaobao-risk-cache.test.ts`
+- Modify: `apps/web/lib/xiaobao-risk.ts`
+
+- [ ] **Step 1: Write failing cache tests**
+
+Create `apps/web/lib/xiaobao-risk-cache.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { findCachedInsight, upsertInsight, upsertDailySnapshot } from './xiaobao-risk-cache';
+
+test('findCachedInsight returns matching signature only', () => {
+ const rows = [{ versionId: 'ver-1', riskSignature: 'a', insight: { summary: '旧解读', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' }];
+ assert.equal(findCachedInsight(rows, 'ver-1', 'a')?.insight.summary, '旧解读');
+ assert.equal(findCachedInsight(rows, 'ver-1', 'b'), undefined);
+});
+
+test('upsertDailySnapshot keeps one snapshot per version and date', () => {
+ const rows = upsertDailySnapshot([], { versionId: 'ver-1', date: '2026-06-29', riskScore: 50, riskLevel: 'attention', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-29T01:00:00.000Z' });
+ const next = upsertDailySnapshot(rows, { ...rows[0], riskScore: 70, createdAt: '2026-06-29T02:00:00.000Z' });
+ assert.equal(next.length, 1);
+ assert.equal(next[0].riskScore, 70);
+});
+
+test('upsertInsight replaces existing version signature pair', () => {
+ const first = upsertInsight([], { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'A', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T01:00:00.000Z' }, generatedAt: '2026-06-29T01:00:00.000Z' });
+ const second = upsertInsight(first, { versionId: 'ver-1', riskSignature: 'a', insight: { summary: 'B', why: [], forecast: '', suggestedActions: [], ownerHints: [], generatedAt: '2026-06-29T02:00:00.000Z' }, generatedAt: '2026-06-29T02:00:00.000Z' });
+ assert.equal(second.length, 1);
+ assert.equal(second[0].insight.summary, 'B');
+});
+```
+
+- [ ] **Step 2: Run cache tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-cache
+```
+
+Expected: FAIL because `apps/web/lib/xiaobao-risk-cache.ts` does not exist.
+
+- [ ] **Step 3: Implement cache helpers and store**
+
+Create `apps/web/lib/xiaobao-risk-cache.ts`:
+
+```ts
+import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
+
+export interface XiaobaoRiskInsight {
+ summary: string;
+ why: string[];
+ forecast: string;
+ recommendedReleaseWindow?: string;
+ suggestedActions: string[];
+ ownerHints: string[];
+ generatedAt: string;
+}
+
+export interface XiaobaoRiskInsightCacheItem {
+ versionId: string;
+ riskSignature: string;
+ insight: XiaobaoRiskInsight;
+ generatedAt: string;
+ providerInfo?: { providerId?: string; model?: string };
+}
+
+export function findCachedInsight(rows: XiaobaoRiskInsightCacheItem[], versionId: string, riskSignature: string): XiaobaoRiskInsightCacheItem | undefined {
+ return rows.find((row) => row.versionId === versionId && row.riskSignature === riskSignature);
+}
+
+export function upsertInsight(rows: XiaobaoRiskInsightCacheItem[], item: XiaobaoRiskInsightCacheItem): XiaobaoRiskInsightCacheItem[] {
+ return [item, ...rows.filter((row) => !(row.versionId === item.versionId && row.riskSignature === item.riskSignature))];
+}
+
+export function upsertDailySnapshot(rows: XiaobaoRiskSnapshot[], item: XiaobaoRiskSnapshot): XiaobaoRiskSnapshot[] {
+ return [item, ...rows.filter((row) => !(row.versionId === item.versionId && row.date === item.date))];
+}
+```
+
+Create `apps/web/stores/useXiaobaoRiskStore.ts`:
+
+```ts
+'use client';
+
+import { create } from 'zustand';
+import { loadServerData, saveServerData } from '@/lib/server-data';
+import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend';
+import type { XiaobaoRiskInsightCacheItem } from '@/lib/xiaobao-risk-cache';
+import { upsertDailySnapshot, upsertInsight } from '@/lib/xiaobao-risk-cache';
+
+interface XiaobaoRiskState {
+ snapshots: XiaobaoRiskSnapshot[];
+ insights: XiaobaoRiskInsightCacheItem[];
+ fetchRiskData: () => Promise;
+ saveSnapshot: (snapshot: XiaobaoRiskSnapshot) => Promise;
+ saveInsight: (insight: XiaobaoRiskInsightCacheItem) => Promise;
+}
+
+export const useXiaobaoRiskStore = create((set, get) => ({
+ snapshots: [],
+ insights: [],
+ fetchRiskData: async () => {
+ const [snapshots, insights] = await Promise.all([
+ loadServerData('xiaobao-risk-snapshots'),
+ loadServerData('xiaobao-risk-insights'),
+ ]);
+ set({ snapshots: snapshots ?? [], insights: insights ?? [] });
+ },
+ saveSnapshot: async (snapshot) => {
+ const snapshots = upsertDailySnapshot(get().snapshots, snapshot);
+ set({ snapshots });
+ await saveServerData('xiaobao-risk-snapshots', snapshots);
+ },
+ saveInsight: async (insight) => {
+ const insights = upsertInsight(get().insights, insight);
+ set({ insights });
+ await saveServerData('xiaobao-risk-insights', insights);
+ },
+}));
+```
+
+- [ ] **Step 4: Expose optional AI insight on risk view model**
+
+Modify `apps/web/lib/xiaobao-risk.ts` imports:
+
+```ts
+import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
+```
+
+Add the optional field to `XiaobaoVersionRisk`:
+
+```ts
+ aiInsight?: XiaobaoRiskInsight;
+```
+
+- [ ] **Step 5: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-cache
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add apps/web/lib/xiaobao-risk.ts apps/web/lib/xiaobao-risk-cache.ts apps/web/lib/xiaobao-risk-cache.test.ts apps/web/stores/useXiaobaoRiskStore.ts
+git commit -m "feat(小宝预警): 增加趋势与解读缓存"
+```
+
+---
+
+### Task 5: Backend AI Risk Interpretation
+
+**Files:**
+- Modify: `packages/shared/src/agent.ts`
+- Create: `apps/server/src/modules/ai/dto/risk-interpret.dto.ts`
+- Create: `apps/server/src/modules/ai/prompts/risk-interpret.ts`
+- Modify: `apps/server/src/modules/ai/ai.service.ts`
+- Modify: `apps/server/src/modules/ai/ai.controller.ts`
+- Modify: `apps/server/src/modules/ai/ai.service.spec.ts`
+
+- [ ] **Step 1: Add shared request and response types**
+
+Modify `packages/shared/src/agent.ts` after decompose response types:
+
+```ts
+export interface AgentRiskReason {
+ key: string;
+ label: string;
+ severity: 'low' | 'medium' | 'high' | 'critical';
+ detail: string;
+}
+
+export interface AgentRiskInterpretRequest {
+ versionId: string;
+ versionName: string;
+ productName?: string;
+ projectName?: string;
+ riskScore: number;
+ riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
+ expectedReleaseDate?: string;
+ forecastReleaseDate?: string;
+ delayDays: number;
+ confidence: number;
+ signals: {
+ unfinishedCount: number;
+ openBugCount: number;
+ criticalBugCount: number;
+ failedTestCount: number;
+ blockedCount: number;
+ silentRiskCount: number;
+ daysToExpectedRelease?: number;
+ };
+ trendSummary: string;
+ reasons: AgentRiskReason[];
+ silentRisks: Array<{ key: string; days: number; detail: string }>;
+ dailyEvidence: {
+ todayDeliveries: string[];
+ todayProgress: string[];
+ todayRisks: string[];
+ progressNotes: string[];
+ needsProgressItems: string[];
+ recentActivityCount: number;
+ lastActivityAt?: string;
+ };
+}
+
+export interface AgentRiskInsight {
+ summary: string;
+ why: string[];
+ forecast: string;
+ recommendedReleaseWindow?: string;
+ suggestedActions: string[];
+ ownerHints: string[];
+ generatedAt: string;
+}
+
+export interface AgentRiskInterpretResponse {
+ ok: true;
+ result: AgentRiskInsight;
+ meta: {
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ durationMs: number;
+ };
+}
+
+export interface AgentRiskInterpretError {
+ ok: false;
+ error: string;
+ code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
+}
+```
+
+- [ ] **Step 2: Write failing server test**
+
+Add to `apps/server/src/modules/ai/ai.service.spec.ts`:
+
+```ts
+it('interprets version risk through the active provider', async () => {
+ const gateway = {
+ getActiveProvider: jest.fn().mockResolvedValue({
+ callTool: jest.fn().mockResolvedValue({
+ toolInput: {
+ summary: '预计延期',
+ why: ['P1 Bug 增加'],
+ forecast: '预计 2026-07-04 可发',
+ recommendedReleaseWindow: '2026-07-04 之后',
+ suggestedActions: ['优先修复 P1 Bug'],
+ ownerHints: ['测试负责人同步失败用例'],
+ generatedAt: '2026-06-29T01:00:00.000Z',
+ },
+ rawModel: 'test-model',
+ inputTokens: 10,
+ outputTokens: 20,
+ }),
+ }),
+ getActiveModel: jest.fn().mockResolvedValue('test-model'),
+ } as any;
+ const service = new AiService(gateway);
+
+ const result = await service.interpretRisk({
+ versionId: 'ver-1',
+ versionName: 'V1.0',
+ riskScore: 70,
+ riskLevel: 'at_risk',
+ delayDays: 1,
+ confidence: 80,
+ signals: {
+ unfinishedCount: 4,
+ openBugCount: 3,
+ criticalBugCount: 3,
+ failedTestCount: 1,
+ blockedCount: 0,
+ silentRiskCount: 0,
+ daysToExpectedRelease: 1,
+ },
+ trendSummary: '风险上升',
+ reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }],
+ silentRisks: [],
+ dailyEvidence: {
+ todayDeliveries: [],
+ todayProgress: [],
+ todayRisks: ['测试不通过'],
+ progressNotes: [],
+ needsProgressItems: [],
+ recentActivityCount: 1,
+ },
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.result.summary).toBe('预计延期');
+});
+```
+
+- [ ] **Step 3: Run server test to verify failure**
+
+Run:
+
+```bash
+pnpm --filter server test -- ai.service
+```
+
+Expected: FAIL because `interpretRisk` does not exist.
+
+- [ ] **Step 4: Implement DTO, prompt, service, and controller**
+
+Create `apps/server/src/modules/ai/dto/risk-interpret.dto.ts`:
+
+```ts
+import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
+class RiskReasonDto {
+ @IsString() key!: string;
+ @IsString() label!: string;
+ @IsString() severity!: string;
+ @IsString() detail!: string;
+}
+
+class RiskDailyEvidenceDto {
+ @IsArray() todayDeliveries!: string[];
+ @IsArray() todayProgress!: string[];
+ @IsArray() todayRisks!: string[];
+ @IsArray() progressNotes!: string[];
+ @IsArray() needsProgressItems!: string[];
+ @IsNumber() recentActivityCount!: number;
+ @IsOptional() @IsString() lastActivityAt?: string;
+}
+
+class RiskSignalsDto {
+ @IsNumber() unfinishedCount!: number;
+ @IsNumber() openBugCount!: number;
+ @IsNumber() criticalBugCount!: number;
+ @IsNumber() failedTestCount!: number;
+ @IsNumber() blockedCount!: number;
+ @IsNumber() silentRiskCount!: number;
+ @IsOptional() @IsNumber() daysToExpectedRelease?: number;
+}
+
+export class RiskInterpretDto {
+ @IsString() versionId!: string;
+ @IsString() versionName!: string;
+ @IsOptional() @IsString() productName?: string;
+ @IsOptional() @IsString() projectName?: string;
+ @IsNumber() riskScore!: number;
+ @IsString() riskLevel!: string;
+ @IsOptional() @IsString() expectedReleaseDate?: string;
+ @IsOptional() @IsString() forecastReleaseDate?: string;
+ @IsNumber() delayDays!: number;
+ @IsNumber() confidence!: number;
+ @ValidateNested() @Type(() => RiskSignalsDto) signals!: RiskSignalsDto;
+ @IsString() trendSummary!: string;
+ @IsArray() @ValidateNested({ each: true }) @Type(() => RiskReasonDto) reasons!: RiskReasonDto[];
+ @IsArray() silentRisks!: Array<{ key: string; days: number; detail: string }>;
+ @ValidateNested() @Type(() => RiskDailyEvidenceDto) dailyEvidence!: RiskDailyEvidenceDto;
+}
+```
+
+Create `apps/server/src/modules/ai/prompts/risk-interpret.ts`:
+
+```ts
+export const RISK_INTERPRET_TOOL_NAME = 'submit_risk_interpretation';
+export const RISK_INTERPRET_TOOL_DESCRIPTION = '返回小宝预警的结构化解释';
+
+export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的“小宝预警”解释助手。
+你只能解释系统给出的规则预测结果和证据,不能编造不存在的数据。
+你需要用项目经理能理解的中文说明:
+1. 当前能不能按期发版;
+2. 为什么有风险;
+3. 预计延期或建议发版窗口;
+4. 需要优先处理的动作;
+5. 哪些负责人或角色需要关注。
+输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。
+如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。`;
+
+export const RISK_INTERPRET_TOOL_INPUT_SCHEMA = {
+ type: 'object',
+ properties: {
+ summary: { type: 'string' },
+ why: { type: 'array', items: { type: 'string' } },
+ forecast: { type: 'string' },
+ recommendedReleaseWindow: { type: 'string' },
+ suggestedActions: { type: 'array', items: { type: 'string' } },
+ ownerHints: { type: 'array', items: { type: 'string' } },
+ generatedAt: { type: 'string' },
+ },
+ required: ['summary', 'why', 'forecast', 'suggestedActions', 'ownerHints', 'generatedAt'],
+};
+```
+
+Modify `apps/server/src/modules/ai/ai.service.ts` imports:
+
+```ts
+import {
+ RISK_INTERPRET_SYSTEM_PROMPT,
+ RISK_INTERPRET_TOOL_NAME,
+ RISK_INTERPRET_TOOL_DESCRIPTION,
+ RISK_INTERPRET_TOOL_INPUT_SCHEMA,
+} from './prompts/risk-interpret';
+import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError, AgentRiskInsight } from '@ftb/shared';
+```
+
+Add method to `AiService`:
+
+```ts
+ async interpretRisk(req: AgentRiskInterpretRequest): Promise {
+ const startedAt = Date.now();
+ let provider;
+ let model: string;
+ try {
+ provider = await this.gateway.getActiveProvider();
+ model = await this.gateway.getActiveModel();
+ } catch (e: any) {
+ return { ok: false, error: e?.message || '未配置 AI 提供商', code: 'NO_PROVIDER' };
+ }
+
+ try {
+ const toolResp = await provider.callTool({
+ systemPrompt: RISK_INTERPRET_SYSTEM_PROMPT,
+ userPrompt: `请基于以下小宝预警规则结果生成解释,禁止编造新事实:\n${JSON.stringify(req, null, 2)}`,
+ tool: {
+ name: RISK_INTERPRET_TOOL_NAME,
+ description: RISK_INTERPRET_TOOL_DESCRIPTION,
+ inputSchema: RISK_INTERPRET_TOOL_INPUT_SCHEMA,
+ },
+ forceTool: true,
+ maxTokens: 3000,
+ model,
+ });
+ const result = toolResp.toolInput as AgentRiskInsight;
+ if (!result.summary || !Array.isArray(result.why) || !Array.isArray(result.suggestedActions)) {
+ return { ok: false, error: 'AI 返回结构不符合预期', code: 'PARSE_ERROR' };
+ }
+ return {
+ ok: true,
+ result,
+ meta: {
+ model: toolResp.rawModel || model,
+ inputTokens: toolResp.inputTokens,
+ outputTokens: toolResp.outputTokens,
+ durationMs: Date.now() - startedAt,
+ },
+ };
+ } catch (e: any) {
+ return { ok: false, error: `AI 服务调用失败:${e.message}`, code: 'API_ERROR' };
+ }
+ }
+```
+
+Modify `apps/server/src/modules/ai/ai.controller.ts`:
+
+```ts
+import { RiskInterpretDto } from './dto/risk-interpret.dto';
+import type { AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';
+
+ @Post('risk-interpret')
+ async interpretRisk(@Body() dto: RiskInterpretDto): Promise {
+ return this.aiService.interpretRisk(dto as any);
+ }
+```
+
+- [ ] **Step 5: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter server test -- ai.service
+pnpm --filter server type-check
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add packages/shared/src/agent.ts apps/server/src/modules/ai/dto/risk-interpret.dto.ts apps/server/src/modules/ai/prompts/risk-interpret.ts apps/server/src/modules/ai/ai.service.ts apps/server/src/modules/ai/ai.controller.ts apps/server/src/modules/ai/ai.service.spec.ts
+git commit -m "feat(小宝预警): 增加 AI 风险解读接口"
+```
+
+---
+
+### Task 6: Client AI Trigger And Cache Orchestration
+
+**Files:**
+- Create: `apps/web/lib/xiaobao-risk-ai.ts`
+- Create: `apps/web/lib/xiaobao-risk-ai.test.ts`
+
+- [ ] **Step 1: Write failing AI trigger tests**
+
+Create `apps/web/lib/xiaobao-risk-ai.test.ts`:
+
+```ts
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { shouldRequestRiskInsight } from './xiaobao-risk-ai';
+
+test('shouldRequestRiskInsight skips on_track', () => {
+ assert.equal(shouldRequestRiskInsight({ riskLevel: 'on_track', riskScore: 10, confidence: 90 } as any, undefined), false);
+});
+
+test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
+ assert.equal(
+ shouldRequestRiskInsight(
+ { riskLevel: 'attention', riskScore: 68, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', signals: { unfinishedCount: 0, openBugCount: 0, criticalBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } } as any,
+ { riskScore: 35, confidence: 80, forecastReleaseDate: '2026-07-02T10:00:00.000Z', openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 } as any,
+ ),
+ true,
+ );
+});
+
+test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
+ const current = {
+ riskLevel: 'attention',
+ riskScore: 52,
+ confidence: 80,
+ forecastReleaseDate: '2026-07-04T10:00:00.000Z',
+ signals: {
+ unfinishedCount: 4,
+ openBugCount: 3,
+ criticalBugCount: 3,
+ failedTestCount: 1,
+ blockedCount: 1,
+ silentRiskCount: 1,
+ daysToExpectedRelease: 1,
+ },
+ } as any;
+ const previous = {
+ riskScore: 50,
+ confidence: 80,
+ forecastReleaseDate: '2026-07-02T10:00:00.000Z',
+ openBugCount: 0,
+ failedTestCount: 0,
+ blockedCount: 0,
+ silentRiskCount: 0,
+ } as any;
+
+ assert.equal(shouldRequestRiskInsight(current, previous), true);
+});
+
+test('shouldRequestRiskInsight triggers high risk levels', () => {
+ assert.equal(shouldRequestRiskInsight({ riskLevel: 'at_risk', riskScore: 60, confidence: 80 } as any, undefined), true);
+ assert.equal(shouldRequestRiskInsight({ riskLevel: 'likely_delayed', riskScore: 80, confidence: 80 } as any, undefined), true);
+ assert.equal(shouldRequestRiskInsight({ riskLevel: 'blocked', riskScore: 90, confidence: 80 } as any, undefined), true);
+});
+```
+
+- [ ] **Step 2: Run trigger test to verify failure**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-ai
+```
+
+Expected: FAIL because `apps/web/lib/xiaobao-risk-ai.ts` does not exist.
+
+- [ ] **Step 3: Implement AI trigger and request mapper**
+
+Create `apps/web/lib/xiaobao-risk-ai.ts`:
+
+```ts
+import type { AgentRiskInterpretRequest, AgentRiskInterpretResponse, AgentRiskInterpretError } from '@ftb/shared';
+import { api } from './api';
+import type { XiaobaoVersionRisk } from './xiaobao-risk';
+import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
+import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
+import { buildRiskSignature } from './xiaobao-risk-trend';
+import { findCachedInsight } from './xiaobao-risk-cache';
+
+type RiskInsightCurrent = Pick;
+type RiskInsightPrevious = Pick;
+
+export function shouldRequestRiskInsight(current: RiskInsightCurrent, previous?: RiskInsightPrevious): boolean {
+ if (current.riskLevel === 'on_track') return false;
+ if (current.riskLevel === 'at_risk' || current.riskLevel === 'likely_delayed' || current.riskLevel === 'blocked') return true;
+ const releaseIsTomorrowWithUnfinishedWork =
+ current.signals.daysToExpectedRelease !== undefined &&
+ current.signals.daysToExpectedRelease <= 1 &&
+ current.signals.unfinishedCount > 0;
+ if (!previous) return releaseIsTomorrowWithUnfinishedWork;
+ const scoreDelta = previous ? current.riskScore - previous.riskScore : 0;
+ const confidenceDelta = previous ? previous.confidence - current.confidence : 0;
+ const forecastDelayMs = current.forecastReleaseDate && previous.forecastReleaseDate
+ ? new Date(current.forecastReleaseDate).getTime() - new Date(previous.forecastReleaseDate).getTime()
+ : 0;
+ return (
+ scoreDelta >= 15 ||
+ confidenceDelta >= 15 ||
+ forecastDelayMs >= 86400000 ||
+ current.signals.criticalBugCount > previous.openBugCount ||
+ current.signals.failedTestCount > previous.failedTestCount ||
+ current.signals.blockedCount > previous.blockedCount ||
+ current.signals.silentRiskCount > previous.silentRiskCount ||
+ releaseIsTomorrowWithUnfinishedWork
+ );
+}
+
+export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
+ return buildRiskSignature({
+ versionId: risk.versionId,
+ riskScore: risk.riskScore,
+ riskLevel: risk.riskLevel,
+ forecastReleaseDate: risk.forecastReleaseDate,
+ openBugCount: risk.signals.criticalBugCount,
+ failedTestCount: risk.signals.failedTestCount,
+ blockedCount: risk.signals.blockedCount,
+ silentRiskCount: risk.signals.silentRiskCount,
+ confidence: risk.confidence,
+ });
+}
+
+export function getReusableInsight(cache: XiaobaoRiskInsightCacheItem[], risk: XiaobaoVersionRisk): XiaobaoRiskInsightCacheItem | undefined {
+ return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
+}
+
+export async function requestRiskInsight(risk: XiaobaoVersionRisk): Promise {
+ const payload: AgentRiskInterpretRequest = {
+ versionId: risk.versionId,
+ versionName: risk.versionName,
+ productName: risk.productName,
+ projectName: risk.projectName,
+ riskScore: risk.riskScore,
+ riskLevel: risk.riskLevel,
+ expectedReleaseDate: risk.expectedReleaseDate,
+ forecastReleaseDate: risk.forecastReleaseDate,
+ delayDays: risk.delayDays,
+ confidence: risk.confidence,
+ signals: risk.signals,
+ trendSummary: risk.trend.summary,
+ reasons: risk.reasons,
+ silentRisks: risk.silentRisks,
+ dailyEvidence: {
+ todayDeliveries: risk.dailyEvidence.todayDeliveries.map((item) => item.summary),
+ todayProgress: risk.dailyEvidence.todayProgress.map((item) => item.summary),
+ todayRisks: risk.dailyEvidence.todayRisks.map((item) => item.summary),
+ progressNotes: risk.dailyEvidence.progressNotes.map((item) => item.summary),
+ needsProgressItems: risk.dailyEvidence.needsProgressItems.map((item) => item.summary),
+ recentActivityCount: risk.dailyEvidence.recentActivityCount,
+ lastActivityAt: risk.dailyEvidence.lastActivityAt,
+ },
+ };
+ return api.post('/ai/risk-interpret', payload);
+}
+
+```
+
+- [ ] **Step 4: Run tests and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-ai
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add apps/web/lib/xiaobao-risk-ai.ts apps/web/lib/xiaobao-risk-ai.test.ts
+git commit -m "feat(小宝预警): 增加 AI 解读触发策略"
+```
+
+---
+
+### Task 7: Xiaobao Warning Page And Navigation
+
+**Files:**
+- Modify: `apps/web/components/layout/Sidebar.tsx`
+- Create: `apps/web/app/xiaobao-warning/page.tsx`
+- Create: `apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx`
+- Create: `apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx`
+
+- [ ] **Step 1: Add navigation item**
+
+Modify `apps/web/components/layout/Sidebar.tsx` imports:
+
+```ts
+import { AlertTriangle, Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
+```
+
+Add item before `与我相关`:
+
+```ts
+ { label: '小宝预警', path: '/xiaobao-warning', icon: AlertTriangle, permission: 'xiaobao.warning:view' },
+ { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
+```
+
+- [ ] **Step 2: Create card component**
+
+Create `apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx`:
+
+```tsx
+'use client';
+
+import { AlertTriangle, ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react';
+import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
+
+const LEVEL_LABEL: Record = {
+ on_track: '可按期',
+ attention: '关注',
+ at_risk: '高风险',
+ likely_delayed: '预计延期',
+ blocked: '阻塞',
+};
+
+const LEVEL_STYLE: Record = {
+ on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200',
+ attention: 'bg-amber-50 text-amber-700 border-amber-200',
+ at_risk: 'bg-orange-50 text-orange-700 border-orange-200',
+ likely_delayed: 'bg-red-50 text-red-700 border-red-200',
+ blocked: 'bg-red-100 text-red-800 border-red-300',
+};
+
+export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) {
+ const TrendIcon = risk.trend.direction === 'up' ? ArrowUpRight : risk.trend.direction === 'down' ? ArrowDownRight : ArrowRight;
+ return (
+
+ );
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 3: Create drawer component**
+
+Create `apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx`:
+
+```tsx
+'use client';
+
+import { X } from 'lucide-react';
+import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
+
+export function XiaobaoWarningDrawer({ risk, onClose, onNavigate }: { risk: XiaobaoVersionRisk; onClose: () => void; onNavigate: () => void }) {
+ return (
+
+
e.stopPropagation()}>
+
+
+
{risk.versionName}
+
小宝预警详情
+
+
+
+
+
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => (
+
+
{reason.label}
+
{reason.detail}
+
+ ))}
+
+
+ {[...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].length === 0
+ ?
+ : [...risk.dailyEvidence.todayDeliveries, ...risk.dailyEvidence.todayProgress, ...risk.dailyEvidence.todayRisks, ...risk.dailyEvidence.progressNotes].map((item) => (
+ {item.summary}
+ ))}
+
+
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item) => (
+ {item.detail}
+ ))}
+
+
+
+ {risk.trend.summary}
+
+
+
+
+
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Empty({ text }: { text: string }) {
+ return {text}
;
+}
+```
+
+- [ ] **Step 4: Create page**
+
+Create `apps/web/app/xiaobao-warning/page.tsx`:
+
+```tsx
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
+import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
+import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer';
+import { useProductStore } from '@/stores/useProductStore';
+import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { useDevTaskStore } from '@/stores/useDevTaskStore';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useBugStore } from '@/stores/useBugStore';
+import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
+import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
+import { useAuthStore } from '@/stores/useAuthStore';
+import { flattenVersions } from '@/lib/derive';
+import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
+import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
+
+export default function XiaobaoWarningPage() {
+ return (
+
+
+
+ );
+}
+
+function XiaobaoWarningContent() {
+ const router = useRouter();
+ const canManage = useHasPermission('xiaobao.warning:manage');
+ const user = useAuthStore((s) => s.user);
+ const { overview, fetchOverview } = useProductStore();
+ const { plans, fetchPlans } = useVersionPlanStore();
+ const { requirements, fetchRequirements } = useRequirementStore();
+ const { tasks: devTasks, fetchTasks } = useDevTaskStore();
+ const { testCases, fetchTestCases } = useTestCaseStore();
+ const { bugs, fetchBugs } = useBugStore();
+ const { activities, fetchActivities } = useWorkActivityStore();
+ const { worklogs, fetchWorklogs } = useTaskWorklogStore();
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => { fetchOverview(); }, [fetchOverview]);
+ useEffect(() => { fetchPlans(); }, [fetchPlans]);
+ useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
+ useEffect(() => { fetchTasks(); }, [fetchTasks]);
+ useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
+ useEffect(() => { fetchBugs(); }, [fetchBugs]);
+ useEffect(() => { fetchActivities(); }, [fetchActivities]);
+ useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
+
+ const risks = useMemo(() => {
+ const versions = flattenVersions(overview)
+ .filter((version) => ['planned', 'developing', 'paused'].includes(version.status))
+ .filter((version) => canManage || (version.members ?? []).some((member) => member.name === user?.name));
+ const versionMap = new Map(versions.map((v) => [v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }]));
+ const requirementVersionMap = new Map();
+ requirements.forEach((requirement) => { if (requirement.versionId) requirementVersionMap.set(requirement.id, requirement.versionId); });
+ const workItems = buildXiaobaoWorkItems({
+ plans,
+ devTasks,
+ testCases,
+ bugs,
+ versionMap,
+ requirementVersionMap,
+ });
+ return versions.map((version) => {
+ const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id);
+ const reqIds = new Set(versionRequirements.map((requirement) => requirement.id));
+ const evidence = buildVersionDailyEvidence({ versionId: version.id, workItems, activities, worklogs });
+ return calcXiaobaoVersionRisk({
+ version,
+ plans: plans.filter((plan) => plan.versionId === version.id),
+ requirements: versionRequirements,
+ devTasks: devTasks.filter((task) => reqIds.has(task.requirementId)),
+ testCases: testCases.filter((testCase) => testCase.versionId === version.id),
+ bugs: bugs.filter((bug) => bug.versionId === version.id),
+ dailyEvidence: evidence,
+ snapshots: [],
+ });
+ }).sort((a, b) => b.riskScore - a.riskScore);
+ }, [overview, canManage, user?.name, requirements, plans, devTasks, testCases, bugs, activities, worklogs]);
+
+ return (
+
+
+
+ {risks.length === 0 ? (
+ 暂无小宝预警
+ ) : (
+
+ {risks.map((risk) => setSelected(risk)} />)}
+
+ )}
+
+ {selected && (
+
setSelected(null)}
+ onNavigate={() => router.push(`/versions/${selected.versionId}`)}
+ />
+ )}
+
+ );
+}
+```
+
+- [ ] **Step 5: Run UI checks and commit**
+
+Run:
+
+```bash
+pnpm --filter web type-check
+pnpm --filter web test -- xiaobao-risk
+curl http://localhost:3000/xiaobao-warning
+```
+
+Expected: type-check PASS, tests PASS, curl returns HTML with HTTP 200 when dev server is running.
+
+Commit:
+
+```bash
+git add apps/web/components/layout/Sidebar.tsx apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
+git commit -m "feat(小宝预警): 增加预警页面"
+```
+
+---
+
+### Task 8: Automatic AI Interpretation In The Page
+
+**Files:**
+- Modify: `apps/web/app/xiaobao-warning/page.tsx`
+- Modify: `apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx`
+- Modify: `apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx`
+
+- [ ] **Step 1: Wire store, cache, and AI calls into page**
+
+Modify `apps/web/app/xiaobao-warning/page.tsx` imports:
+
+```ts
+import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
+import { buildRiskInsightSignature, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
+```
+
+Inside `XiaobaoWarningContent`:
+
+```ts
+ const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
+ useEffect(() => { fetchRiskData(); }, [fetchRiskData]);
+```
+
+Pass snapshots into risk calculation:
+
+```ts
+ snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id),
+```
+
+Add effect after `risks`:
+
+```ts
+ useEffect(() => {
+ const today = new Date().toISOString().slice(0, 10);
+ risks.forEach((risk) => {
+ saveSnapshot({
+ versionId: risk.versionId,
+ date: today,
+ riskScore: risk.riskScore,
+ riskLevel: risk.riskLevel,
+ forecastReleaseDate: risk.forecastReleaseDate,
+ openBugCount: risk.signals.criticalBugCount,
+ failedTestCount: risk.signals.failedTestCount,
+ blockedCount: risk.signals.blockedCount,
+ silentRiskCount: risk.signals.silentRiskCount,
+ confidence: risk.confidence,
+ createdAt: new Date().toISOString(),
+ }).catch(() => {});
+ });
+ }, [risks, saveSnapshot]);
+
+ useEffect(() => {
+ risks.forEach((risk) => {
+ const cached = getReusableInsight(insights, risk);
+ const today = new Date().toISOString().slice(0, 10);
+ const previous = snapshots
+ .filter((snapshot) => snapshot.versionId === risk.versionId && snapshot.date < today)
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
+ if (cached || !shouldRequestRiskInsight(risk, previous)) return;
+ requestRiskInsight(risk).then((res) => {
+ if (!res.ok) return;
+ saveInsight({
+ versionId: risk.versionId,
+ riskSignature: buildRiskInsightSignature(risk),
+ insight: res.result,
+ generatedAt: new Date().toISOString(),
+ providerInfo: { model: res.meta.model },
+ }).catch(() => {});
+ }).catch(() => {});
+ });
+ }, [risks, insights, snapshots, saveInsight]);
+```
+
+Map cached insight into selected risk by extending local state with cache lookup in render:
+
+```ts
+ const risksWithInsight = useMemo(
+ () => risks.map((risk) => ({ ...risk, aiInsight: getReusableInsight(insights, risk)?.insight })),
+ [risks, insights],
+ );
+
+ const selectedWithInsight = selected
+ ? { ...selected, aiInsight: getReusableInsight(insights, selected)?.insight }
+ : null;
+```
+
+Use `risksWithInsight` for card rendering and `selectedWithInsight` for drawer rendering:
+
+```tsx
+{risksWithInsight.map((risk) => setSelected(risk)} />)}
+
+{selectedWithInsight && (
+ setSelected(null)}
+ onNavigate={() => router.push(`/versions/${selectedWithInsight.versionId}`)}
+ />
+)}
+```
+
+- [ ] **Step 2: Show AI insight on card and drawer**
+
+Modify `XiaobaoWarningCard` after first reason:
+
+```tsx
+ {risk.aiInsight?.summary && {risk.aiInsight.summary}
}
+```
+
+Modify `XiaobaoWarningDrawer` before the navigation button:
+
+```tsx
+
+ {risk.aiInsight ? (
+
+
+
{risk.aiInsight.summary}
+
{risk.aiInsight.forecast}
+
+ {risk.aiInsight.suggestedActions.map((action) => (
+
{action}
+ ))}
+
+ ) : (
+
+ )}
+
+```
+
+- [ ] **Step 3: Run verification and commit**
+
+Run:
+
+```bash
+pnpm --filter web test -- xiaobao-risk-ai
+pnpm --filter web type-check
+```
+
+Expected: PASS.
+
+Commit:
+
+```bash
+git add apps/web/app/xiaobao-warning/page.tsx apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
+git commit -m "feat(小宝预警): 自动生成 AI 风险解读"
+```
+
+---
+
+### Task 9: Documentation Updates
+
+**Files:**
+- Modify: `docs/architecture.md`
+- Modify: `docs/decisions.md`
+- Modify: `docs/workflow.md`
+- Modify: `docs/roadmap.md`
+- Modify: `docs/agent-spec.md`
+
+- [ ] **Step 1: Update architecture**
+
+Add a section to `docs/architecture.md` near “AI Agent 层”:
+
+```md
+## 小宝预警层
+
+小宝预警是版本级风险预测能力,位于主导航“工作区 / 小宝预警”。它不替代版本健康度,而是在版本维度回答“能不能按期发版、为什么有风险、预计延期到多久、风险趋势如何”。
+
+核心规则位于前端纯函数引擎:
+
+- `xiaobao-risk.ts`:版本风险分、等级、预计可发日期、置信度。
+- `xiaobao-risk-evidence.ts`:日报和工作活动证据、静默风险。
+- `xiaobao-risk-trend.ts`:风险快照、趋势和 AI 解读签名。
+
+AI 只解释规则结果,不直接修改业务实体。AI 解读缓存写入 `xiaobao-risk-insights`,风险趋势快照写入 `xiaobao-risk-snapshots`。
+```
+
+- [ ] **Step 2: Update decisions**
+
+Append to `docs/decisions.md`:
+
+```md
+## 33. 小宝预警规则优先,AI 只解释
+
+**问题**:如果直接让 AI 判断版本能否发版,模型可能忽略系统内的任务、Bug、日报和权限事实,导致不可追溯的结论。
+
+**决策**:小宝预警先由确定性规则计算 `riskScore`、`riskLevel`、`forecastReleaseDate`、`confidence`、趋势和静默风险。AI 只在规则触发后解释这些事实,不直接修改任何业务数据。
+
+**理由**:规则结果可测试、可追溯,AI 文案可提升可读性但不能替代系统判断。
+```
+
+- [ ] **Step 3: Update workflow**
+
+Add to `docs/workflow.md`:
+
+```md
+## 小宝预警工作流
+
+小宝预警位于主导航“工作区 / 小宝预警”,显示在“与我相关”上方。可见范围由角色权限控制:
+
+- `xiaobao.warning:manage`:查看所有未结束版本预警。
+- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的版本。
+
+页面打开时会计算当前可见版本风险,并写入当天风险快照。风险达到 `at_risk`、`likely_delayed`、`blocked`,或 `attention` 出现明显风险变化时,会自动生成 AI 解读。AI 解读只写入缓存,不修改版本或任务。
+```
+
+- [ ] **Step 4: Update roadmap and agent spec**
+
+In `docs/roadmap.md`, replace the V3.2 Risk Watch Agent placeholder with:
+
+```md
+### V3.2 — 小宝预警 / Risk Watch Agent
+
+小宝预警以版本发版风险为核心,先通过规则引擎计算风险分、趋势、静默风险和置信度,再由 Risk Watch Agent 自动解释高风险版本。第一版不做后台定时 Agent,风险快照在用户打开页面时写入。
+```
+
+In `docs/agent-spec.md`, replace “Agent 2:Risk Watch Agent(风险预警)— 待规划” with:
+
+```md
+### Agent 2:Risk Watch Agent(小宝预警解释)
+
+**目的**:解释小宝预警规则引擎输出的版本风险结果,生成项目经理可读的风险原因、延期预测、建议发版窗口和处理动作。
+
+**输入**:版本上下文、规则风险结果、风险趋势、静默风险、日报证据和置信度。
+
+**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。
+
+**权限**:读规则结果和压缩证据;写 `xiaobao-risk-insights` 缓存。不得修改 Version、DevTask、TestCase、Bug、Requirement、Member。
+
+**失败回退**:AI 不可用时保留规则预警,前端显示“AI 解读暂不可用”。
+```
+
+- [ ] **Step 5: Run doc scan and commit**
+
+Run:
+
+```powershell
+$terms = @('TO' + 'DO', 'TB' + 'D', '待' + '定', '占' + '位', '未' + '确定')
+rg -n ($terms -join '|') docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md
+```
+
+Expected: no matches introduced by this task.
+
+Commit:
+
+```bash
+git add docs/architecture.md docs/decisions.md docs/workflow.md docs/roadmap.md docs/agent-spec.md
+git commit -m "docs(小宝预警): 更新架构与 Agent 规范"
+```
+
+---
+
+### Task 10: Final Verification
+
+**Files:**
+- No new files.
+
+- [ ] **Step 1: Run full web verification**
+
+Run:
+
+```bash
+pnpm --filter web test
+pnpm --filter web type-check
+```
+
+Expected: both commands PASS.
+
+- [ ] **Step 2: Run server verification**
+
+Run:
+
+```bash
+pnpm --filter server test
+pnpm --filter server type-check
+pnpm --filter server exec prisma validate --schema prisma/schema.prisma
+```
+
+Expected: all commands PASS.
+
+- [ ] **Step 3: Check route manually**
+
+Run when the web dev server is already running:
+
+```bash
+curl http://localhost:3000/xiaobao-warning
+```
+
+Expected: HTTP 200 with HTML content.
+
+- [ ] **Step 4: Inspect git status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: only pre-existing unrelated local changes remain, or a clean working tree if this feature was implemented in an isolated worktree.
+
+- [ ] **Step 5: Final commit if verification-only fixes were needed**
+
+If verification required small fixes, commit only those feature files:
+
+```bash
+git add
+git commit -m "fix(小宝预警): 修复验证问题"
+```
+
+Expected: commit succeeds, or no commit is needed because no fixes were required.
diff --git a/docs/workflow.md b/docs/workflow.md
index 09b0342..876e224 100644
--- a/docs/workflow.md
+++ b/docs/workflow.md
@@ -35,6 +35,22 @@
开发任务从“待开发”切换到“开发中”时,只有当前时间已经超过 `expectedEndAt`(预计截止)才要求填写延后原因。超过预计开始时间但仍未超过预计截止时间,不视为延后。
+## AI 草案领取与计划流程
+
+开发任务和测试用例由 AI 生成后,版本详情里不再显示独立的“待排期”状态:
+
+1. DevTask / TestCase 没有负责人时显示“待领取”。
+2. 点击“领取并填写计划”时同时写入当前用户为负责人,并填写计划起止时间。
+3. 已采纳推荐负责人的 AI 草案已经有负责人,不需要领取,但开始开发/测试前仍必须点击“填写计划”补齐计划起止时间。
+4. DevTask 开始开发前必须同时具备 `assigneeId`、`expectedStartAt`、`expectedEndAt`;TestCase 开始测试前必须同时具备 `assigneeId`、`plannedTestAt`、`plannedEndAt`。
+5. 计划起止时间自动按工作日历计算 `estimateHours`。AI 预估仍保留在 `aiEstimateHours`,不代表负责人已确认排期。
+
+## 开发任务提测流程
+
+1. DevTask 从“自测”转为“已提测”前,任务不能处于阻塞中。
+2. 如果 `isBlocked=true`,必须先解除阻塞并清空阻塞原因,再允许提测。
+3. “已提测”仍然是 DevTask 终态;后续测试通过或失败不回写 DevTask 状态。
+
## 测试轮次流程
测试用例支持按版本开启多轮测试:
@@ -231,8 +247,8 @@ Implementation convention:
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
- 日期选择器接入中国节假日日历。当前内置 2026 年国务院办公厅放假调休安排;其他年份先按周末/工作日兜底。
- 非工作日只提示,不阻止保存;调休工作日按工作日提示。
-- 测试用例计划测试时间字段为 `plannedTestAt`;Bug 计划修复时间字段为 `plannedFixAt`。
-- 测试轮次复制用例时保留计划测试时间、AI 预估和执行预估,清空实际执行记录。
+- 测试用例计划测试时间字段为 `plannedTestAt` / `plannedEndAt`;Bug 计划修复时间字段为 `plannedFixAt`。
+- 测试轮次复制用例时保留计划测试起止时间、AI 预估和执行预估,清空实际执行记录。
## 工时统计口径