feat(ai): 优化拆解目标和负责人推荐
This commit is contained in:
@@ -12,6 +12,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
|
||||
import { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target';
|
||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
@@ -25,6 +26,12 @@ interface Props {
|
||||
version: VersionWithContext;
|
||||
}
|
||||
|
||||
interface DecomposeModalState {
|
||||
result: AgentDecomposeResponse;
|
||||
target: AgentDecomposeTarget;
|
||||
dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number };
|
||||
}
|
||||
|
||||
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
|
||||
const STUCK_THRESHOLD_SEC = 240;
|
||||
|
||||
@@ -44,9 +51,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
|
||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
|
||||
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
|
||||
const [modalState, setModalState] = useState<DecomposeModalState | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const loading = activeTarget !== null;
|
||||
@@ -55,6 +60,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const persistStatus = plan.aiDecomposeStatus;
|
||||
const persistError = plan.aiDecomposeError;
|
||||
const persistAt = plan.aiDecomposeAt;
|
||||
const persistTarget = plan.aiDecomposeTarget;
|
||||
|
||||
// 计算"已用时"
|
||||
const elapsedSec = (() => {
|
||||
@@ -72,6 +78,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
|
||||
const isError = persistStatus === 'error' && !loading;
|
||||
const wasCompleted = persistStatus === 'completed' && !loading;
|
||||
const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null);
|
||||
|
||||
// tick 每秒更新一次,让"已用时"实时跳
|
||||
useEffect(() => {
|
||||
@@ -108,6 +115,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'in_progress',
|
||||
aiDecomposeBy: user?.name,
|
||||
aiDecomposeAt: new Date().toISOString(),
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
|
||||
@@ -134,24 +142,29 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
if (!resp.ok) {
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: resp.error,
|
||||
});
|
||||
} else {
|
||||
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
||||
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
||||
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
|
||||
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
|
||||
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,
|
||||
setModalState({
|
||||
result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) },
|
||||
target,
|
||||
dedupeSummary: {
|
||||
removedDevTaskCount: deduped.removedDevTaskCount,
|
||||
removedTestCaseCount: deduped.removedTestCaseCount,
|
||||
},
|
||||
});
|
||||
setResultTarget(target);
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
}
|
||||
@@ -159,6 +172,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const msg = e?.message || '调用 AI 服务失败';
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: msg,
|
||||
});
|
||||
} finally {
|
||||
@@ -175,10 +189,11 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
|
||||
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
|
||||
const showSpinner = isInProgress && runningTarget === target;
|
||||
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
onClick={() => handleClick(target)}
|
||||
disabled={isInProgress}
|
||||
@@ -231,15 +246,15 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
{modalState && (
|
||||
<DecomposeReportModal
|
||||
result={result}
|
||||
result={modalState.result}
|
||||
version={version}
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
target={resultTarget}
|
||||
dedupeSummary={dedupeSummary}
|
||||
onClose={() => setResult(null)}
|
||||
target={modalState.target}
|
||||
dedupeSummary={modalState.dedupeSummary}
|
||||
onClose={() => setModalState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||||
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
||||
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
AgentDecomposeResponse,
|
||||
@@ -41,11 +42,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
|
||||
|
||||
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||
new Set(data.devTaskDrafts.map((_, i) => i)),
|
||||
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
|
||||
);
|
||||
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
|
||||
new Set(data.testCaseDrafts.map((_, i) => i)),
|
||||
new Set(showTestDrafts ? data.testCaseDrafts.map((_, i) => i) : []),
|
||||
);
|
||||
const [useRecommendedAssignees, setUseRecommendedAssignees] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -89,70 +91,72 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
return ref;
|
||||
});
|
||||
|
||||
let devCount = 0;
|
||||
for (let i = 0; i < data.devTaskDrafts.length; i++) {
|
||||
if (!selectedDevIdx.has(i)) continue;
|
||||
const draft = data.devTaskDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
if (showDevDrafts) {
|
||||
for (let i = 0; i < data.devTaskDrafts.length; i++) {
|
||||
if (!selectedDevIdx.has(i)) continue;
|
||||
const draft = data.devTaskDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||
|
||||
createTask({
|
||||
requirementId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
assigneeId: '',
|
||||
reviewerId: undefined,
|
||||
priority: draft.priority,
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
actualStartAt: undefined,
|
||||
actualEndAt: undefined,
|
||||
status: 'todo',
|
||||
blockReason: undefined,
|
||||
blockedById: undefined,
|
||||
predecessorIds: undefined,
|
||||
riskLevel: undefined,
|
||||
delayReason: undefined,
|
||||
overdueVersionReason: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
devCount++;
|
||||
createTask({
|
||||
requirementId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name ?? '' : '',
|
||||
reviewerId: undefined,
|
||||
priority: draft.priority,
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
actualStartAt: undefined,
|
||||
actualEndAt: undefined,
|
||||
status: 'todo',
|
||||
blockReason: undefined,
|
||||
blockedById: undefined,
|
||||
predecessorIds: undefined,
|
||||
riskLevel: undefined,
|
||||
delayReason: undefined,
|
||||
overdueVersionReason: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
let tcCount = 0;
|
||||
for (let i = 0; i < data.testCaseDrafts.length; i++) {
|
||||
if (!selectedTcIdx.has(i)) continue;
|
||||
const draft = data.testCaseDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
if (showTestDrafts) {
|
||||
for (let i = 0; i < data.testCaseDrafts.length; i++) {
|
||||
if (!selectedTcIdx.has(i)) continue;
|
||||
const draft = data.testCaseDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||
|
||||
createTestCase({
|
||||
versionId: version.id,
|
||||
requirementId: reqRef?.id,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
priority: draft.priority,
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
assigneeId: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
tcCount++;
|
||||
createTestCase({
|
||||
versionId: version.id,
|
||||
requirementId: reqRef?.id,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
priority: draft.priority,
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name : undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
@@ -162,6 +166,17 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
setTimeout(() => onClose(), 1500);
|
||||
};
|
||||
|
||||
const visibleRecommendationCount = useMemo(() => {
|
||||
const members = version.members ?? [];
|
||||
const devCount = showDevDrafts
|
||||
? data.devTaskDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
|
||||
: 0;
|
||||
const testCount = showTestDrafts
|
||||
? data.testCaseDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
|
||||
: 0;
|
||||
return devCount + testCount;
|
||||
}, [data.devTaskDrafts, data.testCaseDrafts, showDevDrafts, showTestDrafts, version.members]);
|
||||
|
||||
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
|
||||
|
||||
return (
|
||||
@@ -281,7 +296,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的任务</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.devTaskDrafts.map((d, i) => (
|
||||
{data.devTaskDrafts.map((d, i) => {
|
||||
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
|
||||
return (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
@@ -304,6 +321,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
{recommendation && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
title={recommendation.reason ?? ''}
|
||||
>
|
||||
推荐 {recommendation.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{d.description && (
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
|
||||
@@ -320,7 +345,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -336,7 +362,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的用例</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.testCaseDrafts.map((d, i) => (
|
||||
{data.testCaseDrafts.map((d, i) => {
|
||||
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
|
||||
return (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
@@ -359,6 +387,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
{recommendation && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
title={recommendation.reason ?? ''}
|
||||
>
|
||||
推荐 {recommendation.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
||||
{d.description}
|
||||
@@ -375,7 +411,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -383,9 +420,21 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||
已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||
已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
|
||||
</div>
|
||||
{visibleRecommendationCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRecommendedAssignees}
|
||||
onChange={(event) => setUseRecommendedAssignees(event.target.checked)}
|
||||
/>
|
||||
<span>采纳推荐负责人({visibleRecommendationCount})</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
50
apps/web/lib/ai-assignee-recommendation.test.ts
Normal file
50
apps/web/lib/ai-assignee-recommendation.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveRecommendedAssignee } from './ai-assignee-recommendation';
|
||||
import type { VersionMember } from './derive';
|
||||
|
||||
const members: VersionMember[] = [
|
||||
{ name: 'Alice', role: 'frontend' },
|
||||
{ name: 'Bob', role: 'testing' },
|
||||
];
|
||||
|
||||
test('resolves an AI recommended assignee only when the name is a current version member', () => {
|
||||
const recommendation = resolveRecommendedAssignee(
|
||||
{
|
||||
recommendedAssigneeName: 'Alice',
|
||||
recommendedAssigneeReason: 'Frontend implementation matches this member role.',
|
||||
},
|
||||
members,
|
||||
);
|
||||
|
||||
assert.deepEqual(recommendation, {
|
||||
name: 'Alice',
|
||||
role: 'frontend',
|
||||
reason: 'Frontend implementation matches this member role.',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores hallucinated recommended assignee names', () => {
|
||||
const recommendation = resolveRecommendedAssignee(
|
||||
{
|
||||
recommendedAssigneeName: 'Carol',
|
||||
recommendedAssigneeReason: 'Model invented a person.',
|
||||
},
|
||||
members,
|
||||
);
|
||||
|
||||
assert.equal(recommendation, undefined);
|
||||
});
|
||||
|
||||
test('does not resolve case-mismatched member names', () => {
|
||||
const recommendation = resolveRecommendedAssignee(
|
||||
{
|
||||
recommendedAssigneeName: 'alice',
|
||||
recommendedAssigneeReason: 'Lowercase is not an exact member name.',
|
||||
},
|
||||
members,
|
||||
);
|
||||
|
||||
assert.equal(recommendation, undefined);
|
||||
});
|
||||
29
apps/web/lib/ai-assignee-recommendation.ts
Normal file
29
apps/web/lib/ai-assignee-recommendation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { VersionMember } from './derive';
|
||||
|
||||
export interface RecommendedAssigneeDraft {
|
||||
recommendedAssigneeName?: string;
|
||||
recommendedAssigneeReason?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedRecommendedAssignee {
|
||||
name: string;
|
||||
role: VersionMember['role'];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function resolveRecommendedAssignee(
|
||||
draft: object,
|
||||
members: VersionMember[] | undefined,
|
||||
): ResolvedRecommendedAssignee | undefined {
|
||||
const recommendation = draft as RecommendedAssigneeDraft;
|
||||
if (!recommendation.recommendedAssigneeName) return undefined;
|
||||
const member = (members ?? []).find((candidate) => candidate.name === recommendation.recommendedAssigneeName);
|
||||
if (!member) return undefined;
|
||||
|
||||
const reason = recommendation.recommendedAssigneeReason?.trim();
|
||||
return {
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
53
apps/web/lib/ai-decompose-target.test.ts
Normal file
53
apps/web/lib/ai-decompose-target.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { filterDecomposeResultByTarget } from './ai-decompose-target';
|
||||
import type { AgentDecomposeResult } from '@ftb/shared';
|
||||
|
||||
function makeResult(): AgentDecomposeResult {
|
||||
return {
|
||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [
|
||||
{
|
||||
title: '实现登录表单',
|
||||
categoryCode: 'frontend_development',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.5,
|
||||
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
|
||||
},
|
||||
],
|
||||
testCaseDrafts: [
|
||||
{
|
||||
title: '验证登录成功',
|
||||
description: '输入正确账号密码后进入首页',
|
||||
categoryCode: 'test_functional',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.2,
|
||||
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('keeps only dev task drafts for dev task decomposition target', () => {
|
||||
const original = makeResult();
|
||||
const filtered = filterDecomposeResultByTarget(original, 'dev_tasks');
|
||||
|
||||
assert.equal(filtered.devTaskDrafts.length, 1);
|
||||
assert.equal(filtered.testCaseDrafts.length, 0);
|
||||
assert.equal(original.testCaseDrafts.length, 1);
|
||||
});
|
||||
|
||||
test('keeps only test case drafts for test case decomposition target', () => {
|
||||
const filtered = filterDecomposeResultByTarget(makeResult(), 'test_cases');
|
||||
|
||||
assert.equal(filtered.devTaskDrafts.length, 0);
|
||||
assert.equal(filtered.testCaseDrafts.length, 1);
|
||||
});
|
||||
|
||||
test('keeps both draft groups for all decomposition target', () => {
|
||||
const filtered = filterDecomposeResultByTarget(makeResult(), 'all');
|
||||
|
||||
assert.equal(filtered.devTaskDrafts.length, 1);
|
||||
assert.equal(filtered.testCaseDrafts.length, 1);
|
||||
});
|
||||
14
apps/web/lib/ai-decompose-target.ts
Normal file
14
apps/web/lib/ai-decompose-target.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AgentDecomposeResult, AgentDecomposeTarget } from '@ftb/shared';
|
||||
|
||||
export function filterDecomposeResultByTarget(
|
||||
result: AgentDecomposeResult,
|
||||
target: AgentDecomposeTarget = 'all',
|
||||
): AgentDecomposeResult {
|
||||
if (target === 'dev_tasks') {
|
||||
return { ...result, testCaseDrafts: [] };
|
||||
}
|
||||
if (target === 'test_cases') {
|
||||
return { ...result, devTaskDrafts: [] };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -23,4 +23,7 @@ test('defaults and clamps test case AI estimates below half an hour when appropr
|
||||
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);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_form_validation', 0.05), 0.1);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_data_consistency', 2), 0.5);
|
||||
assert.equal(getDefaultTestCaseAiEstimateHours('test_regression'), 0.25);
|
||||
});
|
||||
|
||||
@@ -16,16 +16,30 @@ const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||
|
||||
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
|
||||
test_ui_interaction: { min: 0.1, max: 0.3, fallback: 0.2 },
|
||||
test_form_validation: { min: 0.1, max: 0.25, fallback: 0.2 },
|
||||
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_data_consistency: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_permission: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_boundary: { min: 0.1, max: 0.3, fallback: 0.2 },
|
||||
test_state_flow: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
|
||||
test_regression: { min: 0.15, max: 0.4, fallback: 0.25 },
|
||||
};
|
||||
|
||||
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_ui_interaction: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_form_validation: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_api: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_data_consistency: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_permission: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_boundary: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_state_flow: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_regression: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
};
|
||||
|
||||
function roundToStep(hours: number, step: number): number {
|
||||
|
||||
@@ -12,6 +12,17 @@ import {
|
||||
|
||||
test('preset categories include stable codes and testing group', () => {
|
||||
assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
|
||||
for (const code of [
|
||||
'test_ui_interaction',
|
||||
'test_form_validation',
|
||||
'test_data_consistency',
|
||||
'test_permission',
|
||||
'test_boundary',
|
||||
'test_state_flow',
|
||||
'test_regression',
|
||||
]) {
|
||||
assert.ok(PRESET_CATEGORIES.some((c) => c.code === code && c.group === 'testing'), code);
|
||||
}
|
||||
assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,16 @@ export const PRESET_CATEGORIES: TaskCategory[] = [
|
||||
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
|
||||
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
|
||||
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
|
||||
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
|
||||
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
|
||||
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', color: '#06b6d4', sortOrder: 21, isSystem: true },
|
||||
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', color: '#84cc16', sortOrder: 22, isSystem: true },
|
||||
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 23, isSystem: true },
|
||||
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', color: '#10b981', sortOrder: 24, isSystem: true },
|
||||
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', color: '#8b5cf6', sortOrder: 25, isSystem: true },
|
||||
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 26, isSystem: true },
|
||||
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', color: '#f59e0b', sortOrder: 27, isSystem: true },
|
||||
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', color: '#6366f1', sortOrder: 28, isSystem: true },
|
||||
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 29, isSystem: true },
|
||||
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', color: '#64748b', sortOrder: 30, isSystem: true },
|
||||
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
|
||||
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
|
||||
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentDecomposeTarget } from '@ftb/shared';
|
||||
|
||||
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
||||
export type ProductPlanKind = 'design' | 'review';
|
||||
export type ProductPlanReviewResult = 'passed' | 'failed';
|
||||
@@ -73,6 +75,7 @@ export interface VersionPlan {
|
||||
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
|
||||
aiDecomposeAt?: string;
|
||||
aiDecomposeBy?: string;
|
||||
aiDecomposeTarget?: AgentDecomposeTarget;
|
||||
aiDecomposeError?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user