diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts
index 89b8b48..bce2485 100644
--- a/apps/server/src/modules/ai/ai.service.spec.ts
+++ b/apps/server/src/modules/ai/ai.service.spec.ts
@@ -147,9 +147,48 @@ describe('AiService', () => {
expect(testCaseRequired).toContain('categoryCode');
});
- it('requires estimateHours for test case drafts', () => {
+ it('requires aiEstimateHours for dev task and test case drafts', () => {
+ const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
- expect(testCaseRequired).toContain('estimateHours');
+ expect(devRequired).toContain('aiEstimateHours');
+ expect(devRequired).not.toContain('estimateHours');
+ expect(testCaseRequired).toContain('aiEstimateHours');
+ expect(testCaseRequired).not.toContain('estimateHours');
+ });
+
+ it('passes decomposition target into the model prompt', async () => {
+ const callTool = jest.fn().mockResolvedValue({
+ toolName: 'submit_decompose',
+ toolInput: {
+ report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
+ devTaskDrafts: [],
+ testCaseDrafts: [],
+ },
+ inputTokens: 100,
+ outputTokens: 20,
+ rawModel: 'claude-test',
+ });
+
+ const gateway = {
+ getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
+ getActiveModel: jest.fn().mockResolvedValue('claude-test'),
+ } as unknown as AiGatewayService;
+
+ const service = new AiService(gateway);
+ (service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001:手机号登录');
+
+ await service.decompose({
+ prototypeUrl: 'https://example.com/prototype',
+ requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }],
+ members: [{ name: '张三', role: 'frontend' }],
+ versionId: 'version-1',
+ planId: 'plan-1',
+ target: 'dev_tasks',
+ });
+
+ const userPrompt = callTool.mock.calls[0][0].userPrompt;
+ expect(userPrompt).toContain('本次只重新拆解开发任务');
+ expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
});
});
diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts
index 8c9cf5c..fb5f78e 100644
--- a/apps/server/src/modules/ai/ai.service.ts
+++ b/apps/server/src/modules/ai/ai.service.ts
@@ -12,6 +12,7 @@ import type {
AgentDecomposeResponse,
AgentDecomposeError,
AgentDecomposeResult,
+ AgentDecomposeTarget,
} from '@ftb/shared';
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
@@ -107,7 +108,7 @@ export class AiService {
};
}
- const result = toolResp.toolInput as AgentDecomposeResult;
+ const result = this.applyTargetToResult(toolResp.toolInput as AgentDecomposeResult, req.target);
if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) {
return {
ok: false,
@@ -195,6 +196,7 @@ export class AiService {
.join('\n');
const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).join('\n');
+ const targetInstruction = this.buildTargetInstruction(req.target ?? 'all');
return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型
@@ -215,9 +217,36 @@ ${reqList || '(无)'}
${memberList || '(无)'}
+## 本次拆解目标
+
+${targetInstruction}
+
## 你的任务
按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。
通过 tool 调用 submit_decompose 返回结果。`;
}
+
+ private buildTargetInstruction(target: AgentDecomposeTarget): string {
+ if (target === 'dev_tasks') {
+ return '本次只重新拆解开发任务;devTaskDrafts 正常返回,testCaseDrafts 必须返回空数组。';
+ }
+ if (target === 'test_cases') {
+ return '本次只重新拆解测试用例;testCaseDrafts 正常返回,devTaskDrafts 必须返回空数组。';
+ }
+ return '本次同时拆解开发任务和测试用例;devTaskDrafts 与 testCaseDrafts 都按实际需要返回。';
+ }
+
+ private applyTargetToResult(
+ result: AgentDecomposeResult,
+ target: AgentDecomposeTarget = 'all',
+ ): AgentDecomposeResult {
+ if (target === 'dev_tasks') {
+ return { ...result, testCaseDrafts: [] };
+ }
+ if (target === 'test_cases') {
+ return { ...result, devTaskDrafts: [] };
+ }
+ return result;
+ }
}
diff --git a/apps/server/src/modules/ai/dto/decompose.dto.ts b/apps/server/src/modules/ai/dto/decompose.dto.ts
index 06839bc..5946036 100644
--- a/apps/server/src/modules/ai/dto/decompose.dto.ts
+++ b/apps/server/src/modules/ai/dto/decompose.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class DecomposeReqMemberDto {
@@ -34,6 +34,10 @@ export class DecomposeDto {
@IsString()
planId!: string;
+ @IsOptional()
+ @IsIn(['all', 'dev_tasks', 'test_cases'])
+ target?: 'all' | 'dev_tasks' | 'test_cases';
+
@IsArray()
@ValidateNested({ each: true })
@Type(() => DecomposeReqRequirementDto)
diff --git a/apps/server/src/modules/ai/prompts/decompose.ts b/apps/server/src/modules/ai/prompts/decompose.ts
index 42947a6..bedca9d 100644
--- a/apps/server/src/modules/ai/prompts/decompose.ts
+++ b/apps/server/src/modules/ai/prompts/decompose.ts
@@ -38,15 +38,18 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
- 不输出数据库 categoryId
- 不输出推荐负责人(用户后续手填)
-5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
+5. AI 工时估算(字段名 aiEstimateHours,单位小时)
+ - aiEstimateHours 只代表 AI 对工作量的判断,不代表负责人计划排期
+ - 不要输出 estimateHours、预计开始时间、预计截止时间
- 简单前端字段、文案、展示调整: 0.25-0.5h
- - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
- - 拖拽排序并需要持久化接口: 1-1.5h
- - 简单 CRUD 接口: 0.75-1.5h
- - 数据库字段/索引调整: 0.5h
- - 中等业务规则变更: 1.5-3h
- - 简单功能测试用例执行: 0.25-0.5h
- - API/异常/兼容性测试用例执行: 0.5-1h
+ - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.25-0.5h
+ - 拖拽排序并需要持久化接口: 0.75-1h
+ - 简单 CRUD 接口: 0.5-1h
+ - 数据库字段/索引调整: 0.25-0.5h
+ - 中等业务规则变更: 1-2h
+ - 简单功能测试用例执行: 0.1-0.3h
+ - API/异常测试用例执行: 0.2-0.5h
+ - 兼容性测试用例执行: 0.3-0.75h
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
6. 标题:中文动词开头,简洁
@@ -133,7 +136,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
- estimateHours: { type: 'number' },
+ aiEstimateHours: { type: 'number' },
references: {
type: 'array',
items: {
@@ -148,7 +151,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
minItems: 1,
},
},
- required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'],
+ required: ['title', 'categoryCode', 'priority', 'aiEstimateHours', 'references'],
},
},
testCaseDrafts: {
@@ -163,7 +166,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
- estimateHours: { type: 'number' },
+ aiEstimateHours: { type: 'number' },
references: {
type: 'array',
items: {
@@ -178,7 +181,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
minItems: 1,
},
},
- required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
+ required: ['title', 'description', 'categoryCode', 'priority', 'aiEstimateHours', 'references'],
},
},
},
diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx
index 3b7ae01..98bdc4a 100644
--- a/apps/web/components/dev-task/DevTaskCreateModal.tsx
+++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx
@@ -183,7 +183,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
-
+
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
index 94763ce..90e4f5e 100644
--- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
+++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
@@ -50,6 +50,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
const [showBlockInput, setShowBlockInput] = useState(false);
const estimate = useMemo(() => getEstimateHours(task), [task]);
+ const executorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0 ? task.estimateHours : undefined;
+ const aiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0 ? task.aiEstimateHours : undefined;
const actual = useMemo(() => getActualHours(task), [task]);
const overrun = actual > estimate && estimate > 0;
const requireDelay = task.status === 'todo' && needsDelayReason(task);
@@ -217,8 +219,12 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
{task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}
- 预计耗时
- {formatHours(estimate)}
+ AI 预估
+ {aiEstimate ? formatHours(aiEstimate) : '—'}
+
+
+ 执行预估
+ {executorEstimate ? formatHours(executorEstimate) : '待负责人填写'}
实际耗时
diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx
index e8e79ce..85b991d 100644
--- a/apps/web/components/dev-task/DevTaskRow.tsx
+++ b/apps/web/components/dev-task/DevTaskRow.tsx
@@ -34,8 +34,11 @@ function timeRangeText(task: DevTask): { text: string; tone: string } {
return { text: '', tone: 'text-[var(--ink-muted)]' };
}
-function hoursText(estimate: number, actual: number): { text: string; tone: string } {
- if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
+function hoursText(task: DevTask, estimate: number, actual: number): { text: string; tone: string } {
+ const hasExecutorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0;
+ const hasAiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0;
+ const prefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预';
+ if (actual <= 0) return { text: `${prefix} ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
let tone = 'text-[var(--ink-soft)]';
if (actual > estimate) tone = 'text-red-600';
else if (actual < estimate) tone = 'text-emerald-600';
@@ -46,7 +49,7 @@ export function DevTaskRow({ task, category, onClick }: Props) {
const estimate = getEstimateHours(task);
const actual = getActualHours(task);
const range = timeRangeText(task);
- const hours = hoursText(estimate, actual);
+ const hours = hoursText(task, estimate, actual);
return (
-
+
c.id === tc.categoryId);
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
- const estimateHours = getTestCaseEstimateHours(tc);
+ const executorEstimateHours = typeof tc.estimateHours === 'number' && tc.estimateHours > 0 ? tc.estimateHours : undefined;
+ const aiEstimateHours = typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0 ? tc.aiEstimateHours : undefined;
const actualHours = getTestCaseActualHours(tc);
const [failReason, setFailReason] = useState('');
@@ -159,7 +160,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
优先级:{tc.priority}
任务类型:
负责人:{tc.assigneeId || '-'}
-
预估耗时:{formatWorkHours(estimateHours)}
+
AI 预估:{aiEstimateHours ? formatWorkHours(aiEstimateHours) : '—'}
+
执行预估:{executorEstimateHours ? formatWorkHours(executorEstimateHours) : '待负责人填写'}
创建人:{tc.createdBy}
创建日期:{tc.createdAt.slice(0, 10)}
{tc.startedAt &&
开始执行:{formatDateTime(tc.startedAt)}
}
diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx
index 7271893..9ce463f 100644
--- a/apps/web/components/test-case/TestCaseRow.tsx
+++ b/apps/web/components/test-case/TestCaseRow.tsx
@@ -25,6 +25,9 @@ const PRIORITY_DOT: Record
= {
function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
const estimateHours = getTestCaseEstimateHours(testCase);
const actualHours = getTestCaseActualHours(testCase);
+ const hasExecutorEstimate = typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0;
+ const hasAiEstimate = typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0;
+ const estimatePrefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预';
return (
@@ -40,7 +43,7 @@ function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
- {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
+ {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`}
{bugCount > 0 && (
{bugCount} Bug
diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx
index 5973ad4..0d9c6e1 100644
--- a/apps/web/components/version/AiDecomposeButton.tsx
+++ b/apps/web/components/version/AiDecomposeButton.tsx
@@ -7,9 +7,14 @@ import type { VersionWithContext } from '@/lib/derive';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
+import { useDevTaskStore } from '@/stores/useDevTaskStore';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { api } from '@/lib/api';
+import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
import { DecomposeReportModal } from './DecomposeReportModal';
import type {
+ AgentDecomposeTarget,
AgentDecomposeRequest,
AgentDecomposeResponse,
AgentDecomposeError,
@@ -33,12 +38,18 @@ function formatElapsed(sec: number): string {
export function AiDecomposeButton({ plan, version }: Props) {
const { updatePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
+ const devTasks = useDevTaskStore((s) => s.tasks);
+ const testCases = useTestCaseStore((s) => s.testCases);
+ const categories = useTaskCategoryStore((s) => s.categories);
const user = useAuthStore((s) => s.user);
- const [loading, setLoading] = useState(false);
+ const [activeTarget, setActiveTarget] = useState(null);
const [result, setResult] = useState(null);
+ const [resultTarget, setResultTarget] = useState('all');
+ const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
const [tick, setTick] = useState(0);
const startedAtRef = useRef(null);
+ const loading = activeTarget !== null;
// plan 上的状态(持久化在 localStorage)
const persistStatus = plan.aiDecomposeStatus;
@@ -78,14 +89,20 @@ export function AiDecomposeButton({ plan, version }: Props) {
description: r.description,
}));
- const handleClick = async () => {
+ const targetText = (target: AgentDecomposeTarget) => {
+ if (target === 'dev_tasks') return '开发任务';
+ if (target === 'test_cases') return '测试用例';
+ return '任务和用例';
+ };
+
+ const handleClick = async (target: AgentDecomposeTarget) => {
if (loading) return;
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
startedAtRef.current = Date.now();
setTick(Date.now());
- setLoading(true);
+ setActiveTarget(target);
updatePlan(plan.id, {
aiDecomposeStatus: 'in_progress',
@@ -105,6 +122,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
members,
versionId: version.id,
planId: plan.id,
+ target,
};
try {
@@ -119,7 +137,19 @@ export function AiDecomposeButton({ plan, version }: Props) {
aiDecomposeError: resp.error,
});
} else {
- setResult(resp);
+ const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
+ const deduped = filterDuplicateDecomposeDrafts(resp.result, {
+ existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
+ existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
+ categories,
+ requirements: linkedReqs,
+ });
+ setResult({ ...resp, result: deduped.result });
+ setDedupeSummary({
+ removedDevTaskCount: deduped.removedDevTaskCount,
+ removedTestCaseCount: deduped.removedTestCaseCount,
+ });
+ setResultTarget(target);
updatePlan(plan.id, {
aiDecomposeStatus: 'completed',
aiDecomposeError: undefined,
@@ -132,7 +162,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
aiDecomposeError: msg,
});
} finally {
- setLoading(false);
+ setActiveTarget(null);
startedAtRef.current = null;
}
};
@@ -143,39 +173,48 @@ export function AiDecomposeButton({ plan, version }: Props) {
return (
<>
-
+
+ {(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
+ const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
+ const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
+ return (
+
+ );
+ })}
+
{/* 错误信息:在按钮旁悬浮显示 */}
{isError && persistError && (
@@ -198,6 +237,8 @@ export function AiDecomposeButton({ plan, version }: Props) {
version={version}
plan={plan}
requirements={linkedReqs}
+ target={resultTarget}
+ dedupeSummary={dedupeSummary}
onClose={() => setResult(null)}
/>
)}
diff --git a/apps/web/components/version/DecomposeReportModal.tsx b/apps/web/components/version/DecomposeReportModal.tsx
index c6a3b16..3eac1ec 100644
--- a/apps/web/components/version/DecomposeReportModal.tsx
+++ b/apps/web/components/version/DecomposeReportModal.tsx
@@ -9,9 +9,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
-import { addWorkHours } from '@/lib/work-hours';
-import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
+import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
+import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
import type {
+ AgentDecomposeTarget,
AgentDecomposeResponse,
AgentDevTaskDraft,
AgentTestCaseDraft,
@@ -22,16 +23,22 @@ interface Props {
version: VersionWithContext;
plan: VersionPlan;
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
+ target: AgentDecomposeTarget;
+ dedupeSummary?: { removedDevTaskCount: number; removedTestCaseCount: number };
onClose: () => void;
}
-export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) {
+export function DecomposeReportModal({ result, version, plan, requirements, target, dedupeSummary, onClose }: Props) {
const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const { result: data, meta } = result;
+ const showDevDrafts = target !== 'test_cases';
+ const showTestDrafts = target !== 'dev_tasks';
+ const filteredDuplicateCount =
+ (dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
const [selectedDevIdx, setSelectedDevIdx] = useState>(
new Set(data.devTaskDrafts.map((_, i) => i)),
@@ -90,9 +97,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
const reqRef = refs.find((r) => r.type === 'requirement');
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
- const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
- const startISO = new Date().toISOString();
- const endISO = addWorkHours(startISO, estimateHours);
+ const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
createTask({
requirementId,
@@ -102,9 +107,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
assigneeId: '',
reviewerId: undefined,
priority: draft.priority,
- expectedStartAt: startISO,
- expectedEndAt: endISO,
- estimateHours,
+ expectedStartAt: '',
+ expectedEndAt: '',
+ estimateHours: undefined,
+ aiEstimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
@@ -129,7 +135,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
- const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
+ const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
createTestCase({
versionId: version.id,
@@ -138,7 +144,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
description: draft.description,
categoryId,
priority: draft.priority,
- estimateHours,
+ estimateHours: undefined,
+ aiEstimateHours,
assigneeId: undefined,
references: refs,
aiDraft: true,
@@ -155,6 +162,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
setTimeout(() => onClose(), 1500);
};
+ const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
+
return (
+ {filteredDuplicateCount > 0 && (
+
+ 已自动过滤 {dedupeSummary?.removedDevTaskCount ?? 0} 个重复开发任务、{dedupeSummary?.removedTestCaseCount ?? 0} 个重复测试用例。
+
+ )}
+
{/* 对账报告 */}
对账报告
@@ -192,8 +207,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
{data.report.matched.map((m, i) => (
- -
- {m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
+
-
+
+ {formatReportRequirementLabel(m.reqId, requirements)}
+
+ ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
))}
@@ -210,7 +231,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:
- {data.report.reqOnly.map((id, i) => - {id}
)}
+ {data.report.reqOnly.map((id, i) => {
+ const label = formatReportRequirementLabel(id, requirements);
+ return - {label}
;
+ })}
)}
@@ -248,6 +272,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
{/* DevTask 草案 */}
+ {showDevDrafts && (
开发任务草案({data.devTaskDrafts.length})
@@ -277,7 +302,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
{d.priority}
- {clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
+ AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
{d.description && (
@@ -299,8 +324,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
)}
+ )}
{/* TestCase 草案 */}
+ {showTestDrafts && (
测试用例草案({data.testCaseDrafts.length})
@@ -330,7 +357,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
{d.priority}
- {clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
+ AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
@@ -352,12 +379,13 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
)}
+ )}
{/* Footer */}
- 已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例
+ 已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
)}
+ {plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
+
+
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
+
+ {plan.reviewResult === 'failed' && (
+
+ {getFailureLabels(plan.reviewFailureTypes).length > 0 &&
类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}
}
+ {plan.reviewFailureReason &&
原因:{plan.reviewFailureReason}
}
+
+ )}
+
+ )}
+
{plan.remark && (
备注
@@ -223,22 +313,90 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{/* Complete with result */}
{showComplete && (
-
提交成果
-
setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
-
- setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接
- setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件
-
- {resultType === 'link' ? (
-
setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
+
{getSubmitActionLabel(plan)}
+ {isProductReviewPlan ? (
+ <>
+
+ {(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
+ setReviewResult(result)}
+ className={`h-8 rounded-lg border text-[11px] font-medium ${reviewResult === result ? 'border-[var(--accent)] bg-white text-[var(--accent)]' : 'border-[var(--line)] bg-white/70 text-[var(--ink-soft)]'}`}
+ >
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
+
+ ))}
+
+ {reviewResult === 'failed' && (
+ <>
+
+ {PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
+
+ ))}
+
+
@@ -256,7 +414,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)}
{plan.status === 'in_progress' && (
setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
- 提交完成
+ {getSubmitActionLabel(plan)}
)}
setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
diff --git a/apps/web/components/version/PlanTab.tsx b/apps/web/components/version/PlanTab.tsx
index bdd5a8c..9c43f7d 100644
--- a/apps/web/components/version/PlanTab.tsx
+++ b/apps/web/components/version/PlanTab.tsx
@@ -2,8 +2,17 @@
import { useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
-import type { VersionPlan, PlanTask } from '@/lib/version-plan';
-import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
+import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
+import {
+ calcPlanDuration,
+ formatDuration,
+ calcTotalDuration,
+ calcPlanProgress,
+ calcLinkedReqProgress,
+ PRODUCT_PLAN_KIND_LABEL,
+ PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
+ PRODUCT_PLAN_REVIEW_RESULT_LABEL,
+} from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import { FieldError } from '@/components/FieldError';
import { AiDecomposeButton } from './AiDecomposeButton';
@@ -11,6 +20,7 @@ import type { VersionWithContext } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
+import type { PlanResultPayload } from '@/lib/version-plan-workflow';
interface Props {
plans: VersionPlan[];
@@ -24,7 +34,7 @@ interface Props {
allRequirements?: Requirement[];
onCreate: (data: Omit) => void;
onUpdate: (id: string, data: Partial) => void;
- onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void;
+ onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
onDelete: (id: string) => void;
}
@@ -36,6 +46,24 @@ const STATUS_STYLE = {
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
+function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
+ return plan.productPlanKind ?? 'design';
+}
+
+function getSubmitActionLabel(plan: VersionPlan): string {
+ if (plan.type === 'product') {
+ return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
+ }
+ return '提交成果';
+}
+
+function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
+ if (!types?.length) return [];
+ return types
+ .map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
+ .filter(Boolean) as string[];
+}
+
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState(null);
@@ -96,6 +124,16 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
{STATUS_LABEL[effectiveStatus]}
+ {plan.type === 'product' && (
+
+ {PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
+
+ )}
+ {plan.type === 'product' && plan.reviewResult && (
+
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
+
+ )}
计划:{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}
@@ -120,6 +158,19 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
)}
)}
+ {plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
+
+
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
+
+ {plan.reviewResult === 'failed' && (
+
+ {getFailureLabels(plan.reviewFailureTypes).length > 0 &&
类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}
}
+ {plan.reviewFailureReason &&
原因:{plan.reviewFailureReason}
}
+
+ )}
+
+ )}
{/* 子任务 */}
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
@@ -226,8 +277,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
)}
{plan.status !== 'completed' && completionState.canSubmitResult && (
- 已满足提交成果条件
- setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果
+ 已满足{getSubmitActionLabel(plan)}条件
+ setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">{getSubmitActionLabel(plan)}
)}
@@ -257,6 +308,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
{completingPlan && (
setCompletingPlan(null)}
onSubmit={(result) => {
const response = onComplete(completingPlan.id, result);
@@ -289,6 +341,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
+ const [productPlanKind, setProductPlanKind] = useState(initial?.productPlanKind ?? 'design');
const [tasks, setTasks] = useState(initial?.tasks ?? []);
const [newTaskTitle, setNewTaskTitle] = useState('');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
@@ -318,6 +371,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
startTime,
endTime,
status: initial?.status ?? 'pending',
+ productPlanKind: planType === 'product' ? productPlanKind : undefined,
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
remark: remark.trim() || undefined,
@@ -342,6 +396,34 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
+ {planType === 'product' && (
+
+
+
+ {(['design', 'review'] as ProductPlanKind[]).map((kind) => (
+
+ ))}
+
+
+ )}
@@ -461,15 +543,24 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
);
}
-function CompleteModal({ onClose, onSubmit }: {
+function CompleteModal({ plan, onClose, onSubmit }: {
+ plan: VersionPlan;
onClose: () => void;
- onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
+ onSubmit: (result: PlanResultPayload) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
+ const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
+ const [reviewResult, setReviewResult] = useState
('passed');
+ const [reviewFailureTypes, setReviewFailureTypes] = useState>(new Set());
+ const [reviewFailureReason, setReviewFailureReason] = useState('');
+
+ const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
+ const isProductDesignPlan = productPlanKind === 'design';
+ const isProductReviewPlan = productPlanKind === 'review';
const handleFile = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
@@ -480,35 +571,143 @@ function CompleteModal({ onClose, onSubmit }: {
reader.readAsDataURL(file);
};
- const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
+ const canSubmit = isProductReviewPlan
+ ? reviewResult === 'passed' || (reviewFailureTypes.size > 0 && reviewFailureReason.trim().length > 0)
+ : isProductDesignPlan
+ ? resultTitle.trim().length > 0 && url.trim().length > 0 && prototypeReviewConfirmed
+ : resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
+
+ const toggleFailureType = (type: ProductPlanReviewFailureType) => {
+ const next = new Set(reviewFailureTypes);
+ if (next.has(type)) next.delete(type);
+ else next.add(type);
+ setReviewFailureTypes(next);
+ };
+
+ const handleSubmit = () => {
+ if (isProductReviewPlan) {
+ onSubmit({
+ productPlanKind: 'review',
+ reviewResult,
+ reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
+ reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
+ resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
+ });
+ return;
+ }
+
+ if (isProductDesignPlan) {
+ onSubmit({
+ productPlanKind: 'design',
+ resultType: 'link',
+ resultTitle: resultTitle.trim(),
+ resultUrl: url.trim(),
+ prototypeReviewConfirmed,
+ });
+ return;
+ }
+
+ onSubmit({
+ resultType,
+ resultTitle: resultTitle.trim(),
+ resultUrl: resultType === 'link' ? url.trim() : fileData,
+ resultFileName: fileName || undefined,
+ resultFileData: resultType === 'file' ? fileData : undefined,
+ });
+ };
return (
e.stopPropagation()}>
-
提交成果
+ {getSubmitActionLabel(plan)}
-
-
- setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
-
-
- setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接
- setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件
-
- {resultType === 'link' ? (
-
setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
+ {isProductReviewPlan ? (
+ <>
+
+
+
+ {(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
+ setReviewResult(result)}
+ className={`h-9 rounded-lg border text-[12px] font-medium transition-colors ${reviewResult === result ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
+ >
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
+
+ ))}
+
+
+ {reviewResult === 'failed' && (
+
+
+
+
+ {PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
+
+ )}
+ >
) : (
-
-
- {fileName &&
已选:{fileName}
}
-
+ <>
+ {isProductDesignPlan && (
+
+ 请确认产品方案评审已通过后再提交原型地址;这里不再上传 Axure 文件,AI 后续会优先读取可访问的原型链接。
+
+ )}
+
+
+ setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '如 v1.0 原型地址' : '如 v1.0 产品方案'} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
+
+ {!isProductDesignPlan && (
+
+ setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接
+ setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件
+
+ )}
+ {resultType === 'link' || isProductDesignPlan ? (
+
setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
+ ) : (
+
+
+ {fileName &&
已选:{fileName}
}
+
+ )}
+ {isProductDesignPlan && (
+
+ )}
+ >
)}
取消
- onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认
+ 确认
diff --git a/apps/web/lib/ai-decompose-dedupe.test.ts b/apps/web/lib/ai-decompose-dedupe.test.ts
new file mode 100644
index 0000000..7cb0ce9
--- /dev/null
+++ b/apps/web/lib/ai-decompose-dedupe.test.ts
@@ -0,0 +1,138 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { filterDuplicateDecomposeDrafts } from './ai-decompose-dedupe';
+import type { AgentDecomposeResult } from '@ftb/shared';
+import type { DevTask } from './dev-task';
+import type { TestCase } from './test-case';
+import type { TaskCategory } from './task-category';
+
+const categories: TaskCategory[] = [
+ {
+ id: 'cat-frontend',
+ code: 'frontend_interaction',
+ name: '前端交互',
+ group: 'development',
+ sortOrder: 1,
+ isSystem: true,
+ },
+ {
+ id: 'cat-test-functional',
+ code: 'test_functional',
+ name: '功能测试',
+ group: 'testing',
+ sortOrder: 2,
+ isSystem: true,
+ },
+];
+
+const requirements = [{ id: 'req-1', code: 'REQ001' }];
+
+function makeResult(patch: Partial
): AgentDecomposeResult {
+ return {
+ report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
+ devTaskDrafts: [],
+ testCaseDrafts: [],
+ ...patch,
+ };
+}
+
+test('filters dev task drafts already adopted into existing tasks', () => {
+ const existingDevTasks = [
+ {
+ id: 'task-1',
+ title: '实现拖拽排序',
+ categoryId: 'cat-frontend',
+ references: [
+ { type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
+ { type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
+ ],
+ },
+ ] as DevTask[];
+
+ const result = filterDuplicateDecomposeDrafts(
+ makeResult({
+ devTaskDrafts: [
+ {
+ title: '实现拖拽排序',
+ categoryCode: 'frontend_interaction',
+ priority: 'P2',
+ aiEstimateHours: 0.25,
+ references: [
+ { type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
+ { type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
+ ],
+ },
+ ],
+ }),
+ { existingDevTasks, existingTestCases: [], categories, requirements },
+ );
+
+ assert.equal(result.removedDevTaskCount, 1);
+ assert.equal(result.result.devTaskDrafts.length, 0);
+});
+
+test('filters test case drafts already adopted into existing test cases', () => {
+ const existingTestCases = [
+ {
+ id: 'tc-1',
+ title: '验证拖拽排序成功',
+ categoryId: 'cat-test-functional',
+ references: [
+ { type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
+ { type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
+ ],
+ },
+ ] as TestCase[];
+
+ const result = filterDuplicateDecomposeDrafts(
+ makeResult({
+ testCaseDrafts: [
+ {
+ title: '验证拖拽排序成功',
+ description: '拖拽后顺序保存',
+ categoryCode: 'test_functional',
+ priority: 'P2',
+ aiEstimateHours: 0.2,
+ references: [
+ { type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
+ { type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
+ ],
+ },
+ ],
+ }),
+ { existingDevTasks: [], existingTestCases, categories, requirements },
+ );
+
+ assert.equal(result.removedTestCaseCount, 1);
+ assert.equal(result.result.testCaseDrafts.length, 0);
+});
+
+test('keeps drafts with same references but different normalized title', () => {
+ const existingDevTasks = [
+ {
+ id: 'task-1',
+ title: '实现拖拽排序',
+ categoryId: 'cat-frontend',
+ references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
+ },
+ ] as DevTask[];
+
+ const result = filterDuplicateDecomposeDrafts(
+ makeResult({
+ devTaskDrafts: [
+ {
+ title: '保存拖拽排序结果',
+ categoryCode: 'frontend_interaction',
+ priority: 'P2',
+ aiEstimateHours: 0.5,
+ references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
+ },
+ ],
+ }),
+ { existingDevTasks, existingTestCases: [], categories, requirements },
+ );
+
+ assert.equal(result.removedDevTaskCount, 0);
+ assert.equal(result.result.devTaskDrafts.length, 1);
+});
diff --git a/apps/web/lib/ai-decompose-dedupe.ts b/apps/web/lib/ai-decompose-dedupe.ts
new file mode 100644
index 0000000..7c97334
--- /dev/null
+++ b/apps/web/lib/ai-decompose-dedupe.ts
@@ -0,0 +1,125 @@
+import type {
+ AgentDecomposeResult,
+ AgentDevTaskDraft,
+ AgentReference,
+ AgentTestCaseDraft,
+} from '@ftb/shared';
+import type { DevTask, Reference } from './dev-task';
+import type { TestCase } from './test-case';
+import type { TaskCategory } from './task-category';
+
+interface RequirementIdentity {
+ id: string;
+ code: string;
+}
+
+export interface DecomposeDedupeInput {
+ existingDevTasks: DevTask[];
+ existingTestCases: TestCase[];
+ categories: TaskCategory[];
+ requirements: RequirementIdentity[];
+}
+
+export interface DecomposeDedupeResult {
+ result: AgentDecomposeResult;
+ removedDevTaskCount: number;
+ removedTestCaseCount: number;
+}
+
+export function filterDuplicateDecomposeDrafts(
+ result: AgentDecomposeResult,
+ input: DecomposeDedupeInput,
+): DecomposeDedupeResult {
+ const devFingerprints = new Set(
+ input.existingDevTasks
+ .map((task) => existingItemFingerprint(task.title, task.categoryId, task.references ?? [], input))
+ .filter(Boolean),
+ );
+ const testCaseFingerprints = new Set(
+ input.existingTestCases
+ .map((testCase) => existingItemFingerprint(testCase.title, testCase.categoryId, testCase.references ?? [], input))
+ .filter(Boolean),
+ );
+
+ const filteredDevTaskDrafts = filterDrafts(result.devTaskDrafts, devFingerprints, input, draftFingerprint);
+ const filteredTestCaseDrafts = filterDrafts(result.testCaseDrafts, testCaseFingerprints, input, draftFingerprint);
+
+ return {
+ result: {
+ ...result,
+ devTaskDrafts: filteredDevTaskDrafts.items,
+ testCaseDrafts: filteredTestCaseDrafts.items,
+ },
+ removedDevTaskCount: result.devTaskDrafts.length - filteredDevTaskDrafts.items.length,
+ removedTestCaseCount: result.testCaseDrafts.length - filteredTestCaseDrafts.items.length,
+ };
+}
+
+function filterDrafts(
+ drafts: T[],
+ existingFingerprints: Set,
+ input: DecomposeDedupeInput,
+ getFingerprint: (draft: T, input: DecomposeDedupeInput) => string,
+): { items: T[] } {
+ const seen = new Set();
+ const items: T[] = [];
+ for (const draft of drafts) {
+ const fingerprint = getFingerprint(draft, input);
+ if (existingFingerprints.has(fingerprint) || seen.has(fingerprint)) continue;
+ seen.add(fingerprint);
+ items.push(draft);
+ }
+ return { items };
+}
+
+function draftFingerprint(
+ draft: AgentDevTaskDraft | AgentTestCaseDraft,
+ input: DecomposeDedupeInput,
+): string {
+ return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
+}
+
+function existingItemFingerprint(
+ title: string,
+ categoryId: string,
+ references: Reference[],
+ input: DecomposeDedupeInput,
+): string {
+ const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
+ return buildFingerprint(title, categoryCode, references, input);
+}
+
+function buildFingerprint(
+ title: string,
+ categoryCode: string,
+ references: Array,
+ input: DecomposeDedupeInput,
+): string {
+ const refKey = references
+ .map((ref) => normalizeReferenceKey(ref, input.requirements))
+ .filter(Boolean)
+ .sort()
+ .join('|');
+ return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
+}
+
+function normalizeTitle(title: string): string {
+ return title
+ .trim()
+ .toLowerCase()
+ .replace(/[\s::,,.。;;、\-—_]/g, '');
+}
+
+function normalizeReferenceKey(
+ ref: AgentReference | Reference,
+ requirements: RequirementIdentity[],
+): string {
+ const rawId = String(ref.id || '').trim();
+ if (!rawId) return '';
+ if (ref.type === 'requirement') {
+ const matched = requirements.find((requirement) => requirement.id === rawId || requirement.code === rawId);
+ return `requirement:${matched?.id ?? rawId}`;
+ }
+ return `${ref.type}:${rawId.toLowerCase()}`;
+}
+
diff --git a/apps/web/lib/ai-decompose-report.test.ts b/apps/web/lib/ai-decompose-report.test.ts
new file mode 100644
index 0000000..0a064ca
--- /dev/null
+++ b/apps/web/lib/ai-decompose-report.test.ts
@@ -0,0 +1,32 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { formatReportRequirementLabel } from './ai-decompose-report';
+
+const requirements = [
+ {
+ id: 'rea-17823017283970',
+ code: 'REQ0010',
+ title: '支持主题拖拽排序',
+ description: '用户可以调整主题顺序',
+ },
+];
+
+test('formatReportRequirementLabel resolves internal requirement id to code and title', () => {
+ assert.equal(
+ formatReportRequirementLabel('rea-17823017283970', requirements),
+ 'REQ0010 支持主题拖拽排序',
+ );
+});
+
+test('formatReportRequirementLabel resolves requirement code to code and title', () => {
+ assert.equal(
+ formatReportRequirementLabel('REQ0010', requirements),
+ 'REQ0010 支持主题拖拽排序',
+ );
+});
+
+test('formatReportRequirementLabel falls back to raw id when requirement is unknown', () => {
+ assert.equal(formatReportRequirementLabel('rea-missing', requirements), 'rea-missing');
+});
+
diff --git a/apps/web/lib/ai-decompose-report.ts b/apps/web/lib/ai-decompose-report.ts
new file mode 100644
index 0000000..69dd642
--- /dev/null
+++ b/apps/web/lib/ai-decompose-report.ts
@@ -0,0 +1,13 @@
+export interface ReportRequirement {
+ id: string;
+ code: string;
+ title: string;
+ description?: string;
+}
+
+export function formatReportRequirementLabel(reqId: string, requirements: ReportRequirement[]): string {
+ const requirement = requirements.find((item) => item.id === reqId || item.code === reqId);
+ if (!requirement) return reqId;
+ return `${requirement.code} ${requirement.title}`;
+}
+
diff --git a/apps/web/lib/ai-estimation-policy.test.ts b/apps/web/lib/ai-estimation-policy.test.ts
index a057dd6..a4f677f 100644
--- a/apps/web/lib/ai-estimation-policy.test.ts
+++ b/apps/web/lib/ai-estimation-policy.test.ts
@@ -2,24 +2,25 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
- clampDevEstimateHours,
- clampTestCaseEstimateHours,
- getDefaultTestCaseEstimateHours,
+ clampDevAiEstimateHours,
+ clampTestCaseAiEstimateHours,
+ getDefaultTestCaseAiEstimateHours,
} from './ai-estimation-policy';
test('clamps simple frontend interaction to AI-assisted range', () => {
- assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
- assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
+ assert.equal(clampDevAiEstimateHours('frontend_interaction', 5), 0.5);
+ assert.equal(clampDevAiEstimateHours('frontend_interaction', 0.1), 0.25);
});
test('keeps backend API within strict range', () => {
- assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
- assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
+ assert.equal(clampDevAiEstimateHours('backend_api', 0.25), 0.5);
+ assert.equal(clampDevAiEstimateHours('backend_api', 2), 1);
});
-test('defaults and clamps test case estimates', () => {
- assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
- assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
- assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
- assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
+test('defaults and clamps test case AI estimates below half an hour when appropriate', () => {
+ assert.equal(getDefaultTestCaseAiEstimateHours('test_functional'), 0.2);
+ assert.equal(clampTestCaseAiEstimateHours('test_functional', 2), 0.3);
+ assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
+ assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
+ assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
});
diff --git a/apps/web/lib/ai-estimation-policy.ts b/apps/web/lib/ai-estimation-policy.ts
index 05b5809..815ddb7 100644
--- a/apps/web/lib/ai-estimation-policy.ts
+++ b/apps/web/lib/ai-estimation-policy.ts
@@ -3,41 +3,58 @@ import type { AgentTaskCategoryCode } from '@ftb/shared';
type EstimateRange = { min: number; max: number; fallback: number };
const DEV_ESTIMATE_RANGES: Record = {
- frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
- frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
- backend_development: { min: 1, max: 3, fallback: 2 },
- backend_api: { min: 0.75, max: 1.5, fallback: 1 },
- database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
- api_integration: { min: 0.75, max: 1.5, fallback: 1 },
- data_processing: { min: 1, max: 2, fallback: 1.5 },
- implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
+ frontend_development: { min: 0.25, max: 1, fallback: 0.5 },
+ frontend_interaction: { min: 0.25, max: 0.5, fallback: 0.25 },
+ backend_development: { min: 0.75, max: 2, fallback: 1.25 },
+ backend_api: { min: 0.5, max: 1, fallback: 0.75 },
+ database_schema: { min: 0.25, max: 0.5, fallback: 0.25 },
+ api_integration: { min: 0.5, max: 1.25, fallback: 0.75 },
+ data_processing: { min: 0.75, max: 1.5, fallback: 1 },
+ implementation_support: { min: 0.25, max: 1, fallback: 0.5 },
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
};
const TEST_ESTIMATE_RANGES: Record = {
- test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
- test_api: { min: 0.5, max: 1, fallback: 0.5 },
- test_exception: { min: 0.5, max: 1, fallback: 0.5 },
- test_compatibility: { min: 0.5, max: 1, fallback: 1 },
+ test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
+ test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
+ test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
+ test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
};
-function roundQuarterHour(hours: number): number {
- return Math.round(hours * 4) / 4;
+const EXECUTOR_TEST_ESTIMATE_RANGES: Record = {
+ test_functional: { min: 0.25, max: 2, fallback: 0.5 },
+ test_api: { min: 0.25, max: 2, fallback: 0.5 },
+ test_exception: { min: 0.25, max: 2, fallback: 0.5 },
+ test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
+};
+
+function roundToStep(hours: number, step: number): number {
+ return Number((Math.round(hours / step) * step).toFixed(2));
}
-function clampToRange(raw: number | undefined, range: EstimateRange): number {
+function clampToRange(raw: number | undefined, range: EstimateRange, step: number): number {
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
- return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
+ return roundToStep(Math.min(range.max, Math.max(range.min, base)), step);
}
-export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
- return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
+export function clampDevAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
+ return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.25, max: 1.5, fallback: 0.75 }, 0.25);
}
-export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
+export function getDefaultTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
}
-export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
- return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
+export function clampTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
+ return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional, 0.1);
+}
+
+export const clampDevEstimateHours = clampDevAiEstimateHours;
+
+export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
+ return (EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional).fallback;
+}
+
+export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
+ return clampToRange(raw, EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional, 0.25);
}
diff --git a/apps/web/lib/dev-task.test.ts b/apps/web/lib/dev-task.test.ts
new file mode 100644
index 0000000..b682260
--- /dev/null
+++ b/apps/web/lib/dev-task.test.ts
@@ -0,0 +1,52 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { getEstimateHours, type DevTask } from './dev-task';
+
+function makeTask(patch: Partial): DevTask {
+ return {
+ id: 'task-1',
+ taskNo: 'DEV-001',
+ requirementId: 'req-1',
+ title: '实现筛选',
+ categoryId: 'cat-frontend',
+ assigneeId: '张三',
+ priority: 'P2',
+ expectedStartAt: '',
+ expectedEndAt: '',
+ status: 'todo',
+ isBlocked: false,
+ createdBy: 'tester',
+ createdAt: '2026-06-26T01:00:00.000Z',
+ updatedAt: '2026-06-26T01:00:00.000Z',
+ ...patch,
+ };
+}
+
+test('getEstimateHours prefers executor estimate over AI estimate', () => {
+ const task = makeTask({
+ estimateHours: 1.5,
+ aiEstimateHours: 0.5,
+ });
+
+ assert.equal(getEstimateHours(task), 1.5);
+});
+
+test('getEstimateHours falls back to AI estimate before schedule-derived estimate', () => {
+ const task = makeTask({
+ aiEstimateHours: 0.25,
+ expectedStartAt: '2026-06-26T01:00:00.000Z',
+ expectedEndAt: '2026-06-26T03:00:00.000Z',
+ });
+
+ assert.equal(getEstimateHours(task), 0.25);
+});
+
+test('getEstimateHours uses schedule when no executor or AI estimate exists', () => {
+ const task = makeTask({
+ expectedStartAt: '2026-06-26T01:00:00.000Z',
+ expectedEndAt: '2026-06-26T03:00:00.000Z',
+ });
+
+ assert.equal(getEstimateHours(task), 2);
+});
diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts
index fbd0abe..604b8e6 100644
--- a/apps/web/lib/dev-task.ts
+++ b/apps/web/lib/dev-task.ts
@@ -26,6 +26,7 @@ export interface DevTask {
expectedStartAt: string;
expectedEndAt: string;
estimateHours?: number;
+ aiEstimateHours?: number;
actualStartAt?: string;
actualEndAt?: string;
@@ -98,12 +99,19 @@ export function formatHours(hours: number): string {
export function getEstimateHours(task: DevTask): number {
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
- return Math.round(task.estimateHours * 2) / 2;
+ return roundEffortHours(task.estimateHours);
+ }
+ if (typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0) {
+ return roundEffortHours(task.aiEstimateHours);
}
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
}
+function roundEffortHours(hours: number): number {
+ return Number(hours.toFixed(2));
+}
+
export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? now.toISOString();
diff --git a/apps/web/lib/test-case.test.ts b/apps/web/lib/test-case.test.ts
index ea46c74..07623b3 100644
--- a/apps/web/lib/test-case.test.ts
+++ b/apps/web/lib/test-case.test.ts
@@ -43,7 +43,7 @@ test('normalizeTestCase keeps existing categoryId', () => {
assert.equal(tc.categoryId, 'cat-test-api');
});
-test('normalizeTestCase backfills missing estimateHours', () => {
+test('normalizeTestCase keeps missing executor estimate empty', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
@@ -57,8 +57,45 @@ test('normalizeTestCase backfills missing estimateHours', () => {
updatedAt: '2026-06-25',
} as any);
- assert.equal(tc.estimateHours, 0.5);
- assert.equal(getTestCaseEstimateHours(tc), 0.5);
+ assert.equal(tc.estimateHours, undefined);
+ assert.equal(getTestCaseEstimateHours(tc), 0);
+});
+
+test('getTestCaseEstimateHours prefers executor estimate over AI estimate', () => {
+ const tc = normalizeTestCase({
+ id: 'tc-1',
+ caseNo: 'TC-001',
+ versionId: 'v1',
+ title: '登录正常',
+ priority: 'P2',
+ status: 'pending',
+ categoryId: 'cat-test-api',
+ estimateHours: 0.7,
+ aiEstimateHours: 0.2,
+ createdBy: 'tester',
+ createdAt: '2026-06-25',
+ updatedAt: '2026-06-25',
+ } as any);
+
+ assert.equal(getTestCaseEstimateHours(tc), 0.7);
+});
+
+test('getTestCaseEstimateHours falls back to AI estimate', () => {
+ const tc = normalizeTestCase({
+ id: 'tc-1',
+ caseNo: 'TC-001',
+ versionId: 'v1',
+ title: '登录正常',
+ priority: 'P2',
+ status: 'pending',
+ categoryId: 'cat-test-api',
+ aiEstimateHours: 0.2,
+ createdBy: 'tester',
+ createdAt: '2026-06-25',
+ updatedAt: '2026-06-25',
+ } as any);
+
+ assert.equal(getTestCaseEstimateHours(tc), 0.2);
});
test('test case status labels use waiting and testing wording', () => {
diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts
index aaf5f24..6a93d46 100644
--- a/apps/web/lib/test-case.ts
+++ b/apps/web/lib/test-case.ts
@@ -18,6 +18,7 @@ export interface TestCase {
assigneeId?: string;
status: TestCaseStatus;
estimateHours?: number;
+ aiEstimateHours?: number;
startedAt?: string;
completedAt?: string;
executedAt?: string;
@@ -88,7 +89,8 @@ export function normalizeTestCase(testCase: Partial, index = 0): TestC
priority: testCase.priority || 'P2',
assigneeId: testCase.assigneeId,
status: testCase.status || 'pending',
- estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
+ estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
+ aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
@@ -125,9 +127,13 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
}
export function getTestCaseEstimateHours(tc: TestCase): number {
- return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
- ? Math.round(tc.estimateHours * 2) / 2
- : 0.5;
+ if (typeof tc.estimateHours === 'number' && tc.estimateHours > 0) {
+ return Number(tc.estimateHours.toFixed(2));
+ }
+ if (typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0) {
+ return Number(tc.aiEstimateHours.toFixed(2));
+ }
+ return 0;
}
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
diff --git a/apps/web/lib/version-plan-workflow.test.ts b/apps/web/lib/version-plan-workflow.test.ts
index 6356031..c5c90ee 100644
--- a/apps/web/lib/version-plan-workflow.test.ts
+++ b/apps/web/lib/version-plan-workflow.test.ts
@@ -52,6 +52,7 @@ test('allows completion only after result exists', () => {
resultType: 'link',
resultTitle: '原型',
resultUrl: 'https://example.com/prototype',
+ prototypeReviewConfirmed: true,
}));
assert.equal(state.canComplete, true);
});
@@ -83,3 +84,61 @@ test('detects link and file result payloads', () => {
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
});
+
+test('product design plans require a prototype link and review confirmation', () => {
+ const withoutReviewConfirmation = getPlanCompletionState(plan({
+ productPlanKind: 'design',
+ completedRequirementIds: ['r1'],
+ resultType: 'link',
+ resultTitle: 'Prototype',
+ resultUrl: 'https://example.com/prototype',
+ } as Partial));
+ assert.equal(withoutReviewConfirmation.canComplete, false);
+
+ const withReviewConfirmation = getPlanCompletionState(plan({
+ productPlanKind: 'design',
+ completedRequirementIds: ['r1'],
+ resultType: 'link',
+ resultTitle: 'Prototype',
+ resultUrl: 'https://example.com/prototype',
+ prototypeReviewConfirmed: true,
+ } as Partial));
+ assert.equal(withReviewConfirmation.canComplete, true);
+});
+
+test('product design plans do not accept file results', () => {
+ assert.equal(hasPlanResult({
+ productPlanKind: 'design',
+ resultType: 'file',
+ resultTitle: 'Axure file',
+ resultFileName: 'prototype.rp',
+ resultFileData: 'data:application/octet-stream;base64,abc',
+ } as any), false);
+});
+
+test('product review plans can complete with a pass result without prototype link', () => {
+ const state = getPlanCompletionState(plan({
+ productPlanKind: 'review',
+ completedRequirementIds: ['r1'],
+ reviewResult: 'passed',
+ } as Partial));
+ assert.equal(state.canComplete, true);
+});
+
+test('product review plans require failure type and detail when review fails', () => {
+ const missingFailureDetail = getPlanCompletionState(plan({
+ productPlanKind: 'review',
+ completedRequirementIds: ['r1'],
+ reviewResult: 'failed',
+ } as Partial));
+ assert.equal(missingFailureDetail.canComplete, false);
+
+ const completeFailureDetail = getPlanCompletionState(plan({
+ productPlanKind: 'review',
+ completedRequirementIds: ['r1'],
+ reviewResult: 'failed',
+ reviewFailureTypes: ['interaction_flow'],
+ reviewFailureReason: 'Critical path is missing empty and rollback states.',
+ } as Partial));
+ assert.equal(completeFailureDetail.canComplete, true);
+});
diff --git a/apps/web/lib/version-plan-workflow.ts b/apps/web/lib/version-plan-workflow.ts
index 942e58c..f02e819 100644
--- a/apps/web/lib/version-plan-workflow.ts
+++ b/apps/web/lib/version-plan-workflow.ts
@@ -1,11 +1,17 @@
-import type { VersionPlan } from './version-plan';
+import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from './version-plan';
export interface PlanResultPayload {
+ type?: VersionPlan['type'];
+ productPlanKind?: ProductPlanKind;
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
+ prototypeReviewConfirmed?: boolean;
+ reviewResult?: ProductPlanReviewResult;
+ reviewFailureTypes?: ProductPlanReviewFailureType[];
+ reviewFailureReason?: string;
}
export interface PlanCompletionState {
@@ -20,6 +26,23 @@ export interface PlanCompletionState {
}
export function hasPlanResult(plan: PlanResultPayload): boolean {
+ if (plan.productPlanKind === 'review') {
+ if (plan.reviewResult === 'passed') return true;
+ if (plan.reviewResult === 'failed') {
+ return Boolean(plan.reviewFailureTypes?.length && plan.reviewFailureReason?.trim());
+ }
+ return false;
+ }
+
+ if (plan.productPlanKind === 'design') {
+ return Boolean(
+ plan.resultType === 'link'
+ && plan.resultTitle?.trim()
+ && plan.resultUrl?.trim()
+ && plan.prototypeReviewConfirmed,
+ );
+ }
+
const hasTitle = Boolean(plan.resultTitle?.trim());
if (!hasTitle || !plan.resultType) return false;
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
@@ -34,6 +57,11 @@ function requiresChecklist(plan: VersionPlan): boolean {
return plan.type === 'research';
}
+function getProductPlanKind(plan: VersionPlan): ProductPlanKind | undefined {
+ if (plan.type !== 'product') return undefined;
+ return plan.productPlanKind ?? 'design';
+}
+
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
const tasks = plan.tasks ?? [];
const checklistTotal = tasks.length;
@@ -52,8 +80,25 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
}
const canSubmitResult = missingReasons.length === 0;
- const hasResult = hasPlanResult(plan);
- if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
+ const productPlanKind = getProductPlanKind(plan);
+ const resultPlan = productPlanKind ? { ...plan, productPlanKind } : plan;
+ const hasResult = hasPlanResult(resultPlan);
+ if (canSubmitResult && !hasResult) {
+ if (productPlanKind === 'design') {
+ if (plan.resultType === 'file') missingReasons.push('产品设计方案只支持原型链接');
+ if (plan.resultType === 'link' && plan.resultUrl?.trim() && !plan.prototypeReviewConfirmed) {
+ missingReasons.push('请先确认方案评审已通过');
+ } else {
+ missingReasons.push('尚未提交原型链接');
+ }
+ } else if (productPlanKind === 'review') {
+ if (!plan.reviewResult) missingReasons.push('请选择评审结论');
+ if (plan.reviewResult === 'failed' && !plan.reviewFailureTypes?.length) missingReasons.push('请选择评审不通过类型');
+ if (plan.reviewResult === 'failed' && !plan.reviewFailureReason?.trim()) missingReasons.push('请填写评审不通过原因');
+ } else {
+ missingReasons.push('尚未提交成果');
+ }
+ }
return {
checklistTotal,
diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts
index 1367fa6..d207c25 100644
--- a/apps/web/lib/version-plan.ts
+++ b/apps/web/lib/version-plan.ts
@@ -1,4 +1,40 @@
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
+export type ProductPlanKind = 'design' | 'review';
+export type ProductPlanReviewResult = 'passed' | 'failed';
+export type ProductPlanReviewFailureType =
+ | 'requirement_mismatch'
+ | 'information_architecture'
+ | 'interaction_flow'
+ | 'state_coverage'
+ | 'edge_case_missing'
+ | 'business_rule_gap'
+ | 'role_permission_gap'
+ | 'data_rule_gap'
+ | 'copywriting_ambiguity'
+ | 'risk_dependency';
+
+export const PRODUCT_PLAN_KIND_LABEL: Record = {
+ design: '设计方案',
+ review: '方案评审',
+};
+
+export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record = {
+ passed: '评审通过',
+ failed: '评审不通过',
+};
+
+export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [
+ { value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' },
+ { value: 'information_architecture', label: '信息架构或页面层级不清晰' },
+ { value: 'interaction_flow', label: '关键交互流程不闭环' },
+ { value: 'state_coverage', label: '缺少状态、异常或空数据场景' },
+ { value: 'edge_case_missing', label: '边界场景考虑不足' },
+ { value: 'business_rule_gap', label: '业务规则、审批或口径缺失' },
+ { value: 'role_permission_gap', label: '角色权限与可见范围不明确' },
+ { value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' },
+ { value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' },
+ { value: 'risk_dependency', label: '依赖、风险或上线影响未说明' },
+];
export interface PlanTask {
id: string;
@@ -18,11 +54,16 @@ export interface VersionPlan {
tasks?: PlanTask[];
completedRequirementIds?: string[];
linkedRequirementIds?: string[];
+ productPlanKind?: ProductPlanKind;
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
+ prototypeReviewConfirmed?: boolean;
+ reviewResult?: ProductPlanReviewResult;
+ reviewFailureTypes?: ProductPlanReviewFailureType[];
+ reviewFailureReason?: string;
remark?: string;
overdueReason?: string;
actualStartAt?: string;
diff --git a/apps/web/lib/work-effort-engine.test.ts b/apps/web/lib/work-effort-engine.test.ts
index 8d6c4a1..730cb5a 100644
--- a/apps/web/lib/work-effort-engine.test.ts
+++ b/apps/web/lib/work-effort-engine.test.ts
@@ -21,6 +21,15 @@ test('aggregateWorkEffort calculates weighted progress by estimate', () => {
assert.equal(result.progress, 75);
});
+test('aggregateWorkEffort preserves sub-half-hour AI estimates', () => {
+ const result = aggregateWorkEffort([
+ { estimateHours: 0.2, actualHours: 0, progress: 50 },
+ ]);
+
+ assert.equal(result.estimateHours, 0.2);
+ assert.equal(result.progress, 50);
+});
+
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
const result = aggregateWorkEffort([
{ estimateHours: 0, actualHours: 0, progress: 50 },
diff --git a/apps/web/lib/work-effort-engine.ts b/apps/web/lib/work-effort-engine.ts
index 08e1e24..4c28eb8 100644
--- a/apps/web/lib/work-effort-engine.ts
+++ b/apps/web/lib/work-effort-engine.ts
@@ -15,10 +15,15 @@ export function roundHalfHour(hours: number): number {
return Math.round(hours * 2) / 2;
}
+export function roundEstimateHours(hours: number): number {
+ if (!Number.isFinite(hours) || hours <= 0) return 0;
+ return Number(hours.toFixed(2));
+}
+
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
- const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
+ const estimateHours = roundEstimateHours(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
if (estimateHours <= 0) {
diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts
index f7397fb..1937cd2 100644
--- a/apps/web/stores/useTestCaseStore.ts
+++ b/apps/web/stores/useTestCaseStore.ts
@@ -49,7 +49,6 @@ export const useTestCaseStore = create((set, get) => ({
id: createEntityId('tc'),
caseNo: generateCaseNo(list),
status: 'pending',
- estimateHours: data.estimateHours ?? 0.5,
createdAt: now,
updatedAt: now,
} as TestCase);
diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts
index c57d601..d2b3440 100644
--- a/apps/web/stores/useVersionPlanStore.ts
+++ b/apps/web/stores/useVersionPlanStore.ts
@@ -3,6 +3,7 @@ import { create } from 'zustand';
import type { VersionPlan, PlanType } from '@/lib/version-plan';
import { loadServerData, saveServerData } from '@/lib/server-data';
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
+import type { PlanResultPayload } from '@/lib/version-plan-workflow';
const MOCK_PLANS: VersionPlan[] = [];
@@ -22,7 +23,7 @@ interface VersionPlanState {
fetchPlans: () => Promise;
createPlan: (data: Omit) => void;
updatePlan: (id: string, data: Partial) => void;
- completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string };
+ completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string };
deletePlan: (id: string) => void;
}
diff --git a/docs/agent-spec.md b/docs/agent-spec.md
index 28ece87..3b319e2 100644
--- a/docs/agent-spec.md
+++ b/docs/agent-spec.md
@@ -29,6 +29,11 @@
- ❓ 注释含糊无法转化
- 对账报告先呈现给用户,用户审核后才执行写入
+4. **AI 预估不等于执行人预估**
+ - AI 只能输出 `aiEstimateHours`
+ - `estimateHours` 留给负责人/执行人确认后填写
+ - AI 不输出预计开始/截止时间,排期由负责人后续维护
+
4. **历史版本不强制存储**
- 系统不要求历史原型 URL 作为基准
- 拆偏问题靠"对账报告"暴露给用户,由人工补救
@@ -45,7 +50,7 @@
- 当前版本关联需求列表(已被加进 Version 的 Requirement[])
- 版本成员清单(带角色:前端/后端/UI/测试)
-**调用入口**:版本详情页 → 产品方案 Tab → 计划状态 = completed 后,按钮「AI 拆解任务和用例」
+**调用入口**:版本详情页 → 产品方案 Tab → 计划状态 = completed 后,按钮「AI 拆解开发任务」或「AI 拆解测试用例」
**触发条件**:
- 当前版本至少有 1 条 type=product 的 VersionPlan 处于 `completed` 且 resultUrl 非空(提交了原型)
@@ -53,12 +58,16 @@
**输出**:
- 对账报告(结构化文本,含"完美对应/单边/含糊"三段)
-- DevTask 草案数组(每条带 `aiDraft: true` + `references[]`)
-- TestCase 草案数组(每条带 `aiDraft: true` + `references[]`)
+- DevTask 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
+- TestCase 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
+- `target = dev_tasks` 时 `testCaseDrafts` 必须为空数组;`target = test_cases` 时 `devTaskDrafts` 必须为空数组
**写入**:
- 用户确认后,调用 `useDevTaskStore.createTask` 和 `useTestCaseStore.createTestCase`
+- 打开采纳弹窗前,前端先过滤当前版本已采纳过的重复 DevTask/TestCase 草案
- 写入字段中 `aiDraft: true`、`aiDraftAt: ISO时间戳`
+- 写入 `aiEstimateHours`,不写入执行人预估 `estimateHours`
+- DevTask 草案不写预计开始/截止时间,负责人后续排期时再填写
**权限**:
- 读:Version, Requirement, VersionPlan, Member
@@ -172,6 +181,7 @@ interface Reference {
aiDraft?: boolean;
aiDraftAt?: string; // ISO 时间戳
references?: Reference[];
+aiEstimateHours?: number; // AI 预估耗时,单位小时
```
### Agent 输入预期
@@ -182,6 +192,7 @@ interface DecomposeInput {
prototypeUrl: string; // 从产品方案 completed 计划的 resultUrl 派生
requirements: Requirement[]; // 当前版本所属项目下已采纳、可用于本次拆解的需求
members: VersionMember[]; // 该版本参与人员(带角色)
+ target?: 'all' | 'dev_tasks' | 'test_cases';
}
```
@@ -197,12 +208,12 @@ interface DecomposeOutput {
noteOnly: string[]; // 原型注释 ID 列表
ambiguous: Array<{ noteId: string; reason: string }>;
};
- devTaskDrafts: Array & { categoryCode: string; aiDraft: true; aiDraftAt: string; references: Reference[] }>;
- testCaseDrafts: Array & { categoryCode: string; aiDraft: true; aiDraftAt: string; references: Reference[] }>;
+ devTaskDrafts: Array<{ title: string; description?: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
+ testCaseDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
}
```
-要求:AI 不输出数据库 `categoryId`,只输出稳定 `categoryCode`。前端确认写入时按 `TaskCategory.code` 映射成 `categoryId`;映射失败时使用对应分组的默认类型兜底。
+要求:AI 不输出数据库 `categoryId`,只输出稳定 `categoryCode`。前端确认写入时按 `TaskCategory.code` 映射成 `categoryId`;映射失败时使用对应分组的默认类型兜底。AI 不输出 `estimateHours`、预计开始或预计截止。
## 视觉规范
diff --git a/docs/architecture.md b/docs/architecture.md
index d8ed119..c6d44ed 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -150,9 +150,11 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBAC(Owner/Admin/Member/
| DevTask | references | Reference[]? | 引用来源(需求/原型批注) |
| DevTask | aiDraft | boolean? | AI 草案标记 |
| DevTask | aiDraftAt | string? | AI 生成时间戳 |
+| DevTask | aiEstimateHours | number? | AI 预估耗时;执行人预估仍写 estimateHours |
| TestCase | references | Reference[]? | 同上 |
| TestCase | aiDraft | boolean? | 同上 |
| TestCase | aiDraftAt | string? | 同上 |
+| TestCase | aiEstimateHours | number? | AI 预估耗时;执行人预估仍写 estimateHours |
## 版本模块规则层(V2.2 设计约束)
diff --git a/docs/decisions.md b/docs/decisions.md
index 232b0b3..b9b1ffe 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -12,7 +12,7 @@
- `testing` → 80%
- `submitted` → 100%
-**理由**:客观、不允许造假。需求级/版本级进度 = `Σ(estimateHours × 状态推导%) / Σ(estimateHours)`。
+**理由**:客观、不允许造假。需求级/版本级进度 = `Σ(估算耗时 × 状态推导%) / Σ(估算耗时)`。估算耗时优先取执行人填写的 `estimateHours`,没有时才用 AI 草案的 `aiEstimateHours` 兜底。
## 2. 实际工时 — 由开始/完成时间戳计算
@@ -269,3 +269,34 @@
- 需求候选来源属于领域边界;统一 selector 能避免从需求池、项目需求、版本需求之间误取数据。
- AI 不应该猜数据库 id;稳定语义码能兼容管理员调整展示名称,也方便后续扩展更多模型。
- 测试用例带任务类型后,才能知道测试覆盖范围,AI 拆解和人工创建的数据形状也一致。
+
+## 24. AI 预估和执行人预估分离
+
+**问题**:AI 拆解写入 `estimateHours` 会让系统误以为负责人已经确认了工时和排期;同时 AI 会根据原型自动反推预计开始/截止,造成“计划已填”的错觉。
+
+**决策**:
+- AI 输出字段统一改为 `aiEstimateHours`,不再输出/写入 `estimateHours`。
+- `estimateHours` 只表示执行人或负责人确认后的执行预估。
+- 进度和统计口径优先使用 `estimateHours`;缺失时使用 `aiEstimateHours` 兜底;两者都没有时才从计划时间推导或按无估时处理。
+- AI 采纳开发任务时不写预计开始/截止时间,由负责人后续填写计划排期。
+- AI 重新拆解入口拆成“重新拆解开发任务”和“重新拆解测试用例”,请求通过 `target` 控制本次只生成哪类草案。
+- AI 估时允许小于 0.5h,汇总时保留小数精度;实际耗时仍按状态时间戳计算并保持 0.5h 口径。
+
+**理由**:
+- AI 给的是建议,不是负责人承诺。
+- 排期属于执行人计划,不能由拆解 Agent 代填。
+- 独立重拆能避免修改开发任务时顺手覆盖测试用例,降低误采纳风险。
+
+## 25. AI 重新拆解先过滤已采纳重复草案
+
+**问题**:第一次 AI 拆解的 DevTask/TestCase 已经被采纳后,用户再次重新拆解时,模型可能再次输出同一批草案。若直接展示给用户采纳,会造成开发任务和测试用例重复。
+
+**决策**:
+- AI 返回结果后,前端在打开采纳弹窗前先做确定性去重。
+- 开发任务比对范围是当前版本已关联需求下的现有 DevTask;测试用例比对范围是当前版本下的现有 TestCase。
+- 重复判定使用:任务类型 code + 标题规范化 + 引用来源(需求 / 原型批注)一致。
+- 重复草案不进入采纳列表,只在弹窗提示已过滤数量。
+
+**理由**:
+- 去重不能只依赖模型提示,必须有系统规则兜底。
+- 标题也参与签名,避免同一需求/QY 下不同真实工作项被误过滤。
diff --git a/docs/workflow.md b/docs/workflow.md
index c8e36d5..3ab8cea 100644
--- a/docs/workflow.md
+++ b/docs/workflow.md
@@ -135,21 +135,22 @@ Workspace 页面(树筛选 + tab 筛选 + 已完成开关)
↓
4. 从当前项目已采纳需求中关联需求到本版本
↓
-5. 点击「AI 拆解任务和用例」按钮(位于产品方案 Tab)
+5. 点击「AI 拆解开发任务」或「AI 拆解测试用例」按钮(位于产品方案 Tab)
↓
6. Agent 从 product 类型的 completed 计划取 resultUrl 作为原型,
抓取原型 + 关联需求 + 版本成员,输出对账报告 + 任务/用例草案
↓
7. 用户审核对账报告
+ ├─ 系统先自动过滤已采纳过的重复 DevTask/TestCase 草案
├─ 报告全 ✅:直接确认写入
├─ 报告有 ⚠️/❓:选择性放弃部分草案 / 补充信息后重跑
└─ 报告全 ❓:放弃 AI 拆解,人工创建
↓
-8. 确认后,DevTask / TestCase 草案写入对应 Tab,标记 aiDraft: true
+8. 确认后,DevTask / TestCase 草案写入对应 Tab,标记 aiDraft: true,并写入 aiEstimateHours
↓
9. 团队成员在 DevTask Tab 看到紫色边的 AI 草案任务
↓
-10. 任意成员编辑任务(改标题/描述/负责人/优先级/时间/分类),保存后 aiDraft 自动清除
+10. 任意成员编辑任务(改标题/描述/负责人/优先级/时间/分类/执行预估),保存后 aiDraft 自动清除
(changeStatus / setBlocked 等用户主动操作也会清除)
```
@@ -179,6 +180,11 @@ Workspace 页面(树筛选 + tab 筛选 + 已完成开关)
- 多个组件共用的候选筛选规则进 selector/helper。
- AI 输入输出字段先更新 `agent-spec.md` 和 shared type,再改 prompt/schema。
+AI 估时约束:
+- `aiEstimateHours` 是 AI 建议工时,只能由 AI 拆解写入。
+- `estimateHours` 是执行人预估,只能由人工创建/编辑或负责人确认排期时写入。
+- 统计进度优先取 `estimateHours`,没有时取 `aiEstimateHours`,避免 AI 草案在未确认前失去统计权重。
+
版本模块新增规则:
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts
index da06ebd..b3556c1 100644
--- a/packages/shared/src/agent.ts
+++ b/packages/shared/src/agent.ts
@@ -1,5 +1,7 @@
export type AgentRole = 'frontend' | 'backend' | 'ui' | 'testing';
+export type AgentDecomposeTarget = 'all' | 'dev_tasks' | 'test_cases';
+
export type AgentTaskCategoryCode =
| 'frontend_development'
| 'frontend_interaction'
@@ -28,7 +30,7 @@ export interface AgentDevTaskDraft {
description?: string;
categoryCode: AgentTaskCategoryCode;
priority: 'P0' | 'P1' | 'P2' | 'P3';
- estimateHours: number;
+ aiEstimateHours: number;
references: AgentReference[];
}
@@ -37,7 +39,7 @@ export interface AgentTestCaseDraft {
description: string;
categoryCode: AgentTaskCategoryCode;
priority: 'P0' | 'P1' | 'P2' | 'P3';
- estimateHours: number;
+ aiEstimateHours: number;
references: AgentReference[];
}
@@ -75,6 +77,7 @@ export interface AgentDecomposeRequest {
members: AgentDecomposeMember[];
versionId: string;
planId: string;
+ target?: AgentDecomposeTarget;
}
/**