fix(ai): 自动追加AI任务类型

This commit is contained in:
Script Generator
2026-07-02 18:01:38 +08:00
parent 04954356bb
commit a39dde3824
14 changed files with 241 additions and 78 deletions

View File

@@ -404,29 +404,29 @@ describe('AiService', () => {
} }
}); });
it('requires categoryCode for dev task and test case drafts', () => { it('requires free-form taskTypeName without requiring categoryCode for drafts', () => {
const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required; const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
expect(devRequired).toContain('categoryCode'); expect(devRequired).toContain('taskTypeName');
expect(testCaseRequired).toContain('categoryCode'); expect(testCaseRequired).toContain('taskTypeName');
expect(devRequired).not.toContain('categoryCode');
expect(testCaseRequired).not.toContain('categoryCode');
}); });
it('allows fine-grained test case category codes for detailed AI decomposition', () => { it('does not constrain AI decomposition to the manual task category dictionary', () => {
const testCaseCategoryEnum = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.properties const devCategoryCodeSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.properties
.categoryCode.enum; .categoryCode;
const testCaseCategoryCodeSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.properties
.categoryCode;
expect(testCaseCategoryEnum).toEqual( expect(devCategoryCodeSchema).toEqual({ type: 'string' });
expect.arrayContaining([ expect(testCaseCategoryCodeSchema).toEqual({ type: 'string' });
'test_ui_interaction', expect(DECOMPOSE_SYSTEM_PROMPT).toContain('taskTypeName');
'test_form_validation', expect(DECOMPOSE_SYSTEM_PROMPT).toContain('categoryCode 只作为可选映射');
'test_data_consistency', expect(DECOMPOSE_SYSTEM_PROMPT).toContain('不得因为任务类型字典没有覆盖就不拆解');
'test_permission', expect(DECOMPOSE_SYSTEM_PROMPT).toContain('采纳时自动追加到任务类型字典');
'test_boundary', expect(DECOMPOSE_SYSTEM_PROMPT).toContain('测试用例建议类型');
'test_state_flow',
'test_regression',
]),
);
}); });
it('allows optional recommended assignee fields without requiring adoption', () => { it('allows optional recommended assignee fields without requiring adoption', () => {

View File

@@ -56,14 +56,17 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
- 测试用例 description 必须写成结构化短文本,至少包含:前置条件、操作步骤、预期结果;涉及边界/异常/数据时要写明测试数据或状态 - 测试用例 description 必须写成结构化短文本,至少包含:前置条件、操作步骤、预期结果;涉及边界/异常/数据时要写明测试数据或状态
5. 任务类型 5. 任务类型
- 每条开发任务和测试用例都必须输出 categoryCode - 每条开发任务和测试用例都必须输出 taskTypeName使用中文业务类型名例如"人员名片交互"、"预入职数据源"、"姓名展示兼容测试"
- 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration - categoryCode 只作为可选映射;只有能明确映射到已有稳定语义码时才输出
- 测试用例优先使用 test_functional / test_ui_interaction / test_form_validation / test_api / test_data_consistency / test_permission / test_exception / test_boundary / test_state_flow / test_compatibility / test_regression - 不得因为任务类型字典没有覆盖就不拆解;如果没有合适 categoryCode省略 categoryCode但必须保留准确的 taskTypeName
- 采纳时自动追加到任务类型字典AI 不需要也不得输出数据库 categoryId
- 开发任务建议类型:前端开发、前端交互、后端开发、后端接口、数据库设计、接口联调、数据处理、实施支持、文档,或按原型识别出更准确的类型名
- 测试用例建议类型功能测试、UI交互测试、表单校验测试、接口测试、数据一致性测试、权限测试、异常场景测试、边界值测试、状态流转测试、兼容性测试、回归测试或按验收点识别出更准确的类型名
- 不输出数据库 categoryId - 不输出数据库 categoryId
6. 推荐负责人(可选字段) 6. 推荐负责人(可选字段)
- 可以输出 recommendedAssigneeName 和 recommendedAssigneeReason但 recommendedAssigneeName 只能从输入版本成员清单的 members[].name 中精确选择 - 可以输出 recommendedAssigneeName 和 recommendedAssigneeReason但 recommendedAssigneeName 只能从输入版本成员清单的 members[].name 中精确选择
- 开发任务按 categoryCode 优先匹配 frontend/backend 角色;测试用例优先匹配 testing 角色 - 开发任务按 taskTypeName/categoryCode 语义优先匹配 frontend/backend 角色;测试用例优先匹配 testing 角色
- 如果没有明确匹配的版本成员omit recommendedAssigneeName and recommendedAssigneeReason - 如果没有明确匹配的版本成员omit recommendedAssigneeName and recommendedAssigneeReason
- 不要把推荐当作已分配,最终是否采纳由用户确认 - 不要把推荐当作已分配,最终是否采纳由用户确认
@@ -164,20 +167,8 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
properties: { properties: {
title: { type: 'string' }, title: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
categoryCode: { taskTypeName: { type: 'string' },
type: 'string', categoryCode: { type: 'string' },
enum: [
'frontend_development',
'frontend_interaction',
'backend_development',
'backend_api',
'database_schema',
'api_integration',
'data_processing',
'implementation_support',
'documentation',
],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
aiEstimateHours: { type: 'number' }, aiEstimateHours: { type: 'number' },
requirementName: { type: 'string' }, requirementName: { type: 'string' },
@@ -197,7 +188,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
minItems: 1, minItems: 1,
}, },
}, },
required: ['title', 'description', 'categoryCode', 'priority', 'aiEstimateHours', 'references'], required: ['title', 'description', 'taskTypeName', 'priority', 'aiEstimateHours', 'references'],
}, },
}, },
testCaseDrafts: { testCaseDrafts: {
@@ -207,22 +198,8 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
properties: { properties: {
title: { type: 'string' }, title: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
categoryCode: { taskTypeName: { type: 'string' },
type: 'string', categoryCode: { type: 'string' },
enum: [
'test_functional',
'test_ui_interaction',
'test_form_validation',
'test_api',
'test_data_consistency',
'test_permission',
'test_exception',
'test_boundary',
'test_state_flow',
'test_compatibility',
'test_regression',
],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
aiEstimateHours: { type: 'number' }, aiEstimateHours: { type: 'number' },
requirementName: { type: 'string' }, requirementName: { type: 'string' },
@@ -242,7 +219,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
minItems: 1, minItems: 1,
}, },
}, },
required: ['title', 'description', 'categoryCode', 'priority', 'aiEstimateHours', 'references'], required: ['title', 'description', 'taskTypeName', 'priority', 'aiEstimateHours', 'references'],
}, },
}, },
}, },

View File

@@ -8,7 +8,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category'; import { findCategoryByCode, type CategoryGroup } from '@/lib/task-category';
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy'; import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption'; import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption';
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report'; import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
@@ -45,7 +45,7 @@ export function DecomposeReportModal({
}: Props) { }: Props) {
const { createTask } = useDevTaskStore(); const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore(); const { createTestCase } = useTestCaseStore();
const { categories } = useTaskCategoryStore(); const { categories, ensureCategory } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { result: data, meta } = result; const { result: data, meta } = result;
@@ -90,6 +90,18 @@ export function DecomposeReportModal({
setSelectedTcIdx(n); setSelectedTcIdx(n);
}; };
const resolveDraftTaskTypeName = (
draft: AgentDevTaskDraft | AgentTestCaseDraft,
fallbackGroup: Extract<CategoryGroup, 'development' | 'testing'>,
) => {
const explicitName = draft.taskTypeName?.trim();
if (explicitName) return explicitName;
const mappedName = findCategoryByCode(categories, draft.categoryCode)?.name;
if (mappedName) return mappedName;
if (draft.categoryCode) return draft.categoryCode;
return fallbackGroup === 'testing' ? '功能测试' : '开发任务';
};
const handleAdopt = () => { const handleAdopt = () => {
if (submitting) return; if (submitting) return;
setSubmitting(true); setSubmitting(true);
@@ -111,7 +123,8 @@ export function DecomposeReportModal({
const draft = data.devTaskDrafts[i]; const draft = data.devTaskDrafts[i];
const refs = normalizeRefs(draft.references); const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements); const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); const taskTypeName = resolveDraftTaskTypeName(draft, 'development');
const categoryId = ensureCategory(taskTypeName, 'development').id;
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []); const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -152,7 +165,8 @@ export function DecomposeReportModal({
const draft = data.testCaseDrafts[i]; const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references); const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements); const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); const taskTypeName = resolveDraftTaskTypeName(draft, 'testing');
const categoryId = ensureCategory(taskTypeName, 'testing').id;
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []); const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -337,7 +351,7 @@ export function DecomposeReportModal({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span> <span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode} {resolveDraftTaskTypeName(d, 'development')}
</span> </span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority} {d.priority}
@@ -408,7 +422,7 @@ export function DecomposeReportModal({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span> <span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode} {resolveDraftTaskTypeName(d, 'testing')}
</span> </span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority} {d.priority}

View File

@@ -56,6 +56,7 @@ test('filters dev task drafts already adopted into existing tasks', () => {
{ {
title: '实现拖拽排序', title: '实现拖拽排序',
description: '实现范围:支持列表拖拽调整顺序。验收点:拖拽后顺序保持一致。', description: '实现范围:支持列表拖拽调整顺序。验收点:拖拽后顺序保持一致。',
taskTypeName: '前端交互',
categoryCode: 'frontend_interaction', categoryCode: 'frontend_interaction',
priority: 'P2', priority: 'P2',
aiEstimateHours: 0.25, aiEstimateHours: 0.25,
@@ -92,6 +93,7 @@ test('filters test case drafts already adopted into existing test cases', () =>
{ {
title: '验证拖拽排序成功', title: '验证拖拽排序成功',
description: '拖拽后顺序保存', description: '拖拽后顺序保存',
taskTypeName: '功能测试',
categoryCode: 'test_functional', categoryCode: 'test_functional',
priority: 'P2', priority: 'P2',
aiEstimateHours: 0.2, aiEstimateHours: 0.2,
@@ -125,6 +127,7 @@ test('keeps drafts with same references but different normalized title', () => {
{ {
title: '保存拖拽排序结果', title: '保存拖拽排序结果',
description: '实现范围:保存拖拽后的排序结果。验收点:刷新后顺序不丢失。', description: '实现范围:保存拖拽后的排序结果。验收点:刷新后顺序不丢失。',
taskTypeName: '前端交互',
categoryCode: 'frontend_interaction', categoryCode: 'frontend_interaction',
priority: 'P2', priority: 'P2',
aiEstimateHours: 0.5, aiEstimateHours: 0.5,
@@ -138,3 +141,45 @@ test('keeps drafts with same references but different normalized title', () => {
assert.equal(result.removedDevTaskCount, 0); assert.equal(result.removedDevTaskCount, 0);
assert.equal(result.result.devTaskDrafts.length, 1); assert.equal(result.result.devTaskDrafts.length, 1);
}); });
test('filters adopted drafts by AI task type name even when categoryCode is unknown', () => {
const customCategories: TaskCategory[] = [
...categories,
{
id: 'cat-ai-name-card',
code: '人员名片交互',
name: '人员名片交互',
group: 'development',
sortOrder: 3,
isSystem: false,
},
];
const existingDevTasks = [
{
id: 'task-1',
title: '实现同名员工名片入口',
categoryId: 'cat-ai-name-card',
references: [{ type: 'prototype_note', id: 'QY0019', label: 'QY0019' }],
},
] as DevTask[];
const result = filterDuplicateDecomposeDrafts(
makeResult({
devTaskDrafts: [
{
title: '实现同名员工名片入口',
description: '实现范围:姓名后展示名片入口。验收点:点击后展示组织详情。',
taskTypeName: '人员名片交互',
categoryCode: 'ai_unknown',
priority: 'P2',
aiEstimateHours: 0.5,
references: [{ type: 'prototype_note', id: 'QY0019', label: 'QY0019' }],
},
],
}),
{ existingDevTasks, existingTestCases: [], categories: customCategories, requirements },
);
assert.equal(result.removedDevTaskCount, 1);
assert.equal(result.result.devTaskDrafts.length, 0);
});

View File

@@ -76,7 +76,7 @@ function draftFingerprint(
draft: AgentDevTaskDraft | AgentTestCaseDraft, draft: AgentDevTaskDraft | AgentTestCaseDraft,
input: DecomposeDedupeInput, input: DecomposeDedupeInput,
): string { ): string {
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input); return buildFingerprint(draft.title, draftCategoryIdentity(draft, input), draft.references, input);
} }
function existingItemFingerprint( function existingItemFingerprint(
@@ -85,13 +85,25 @@ function existingItemFingerprint(
references: Reference[], references: Reference[],
input: DecomposeDedupeInput, input: DecomposeDedupeInput,
): string { ): string {
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId; const categoryName = input.categories.find((category) => category.id === categoryId)?.name ?? categoryId;
return buildFingerprint(title, categoryCode, references, input); return buildFingerprint(title, categoryName, references, input);
}
function draftCategoryIdentity(
draft: AgentDevTaskDraft | AgentTestCaseDraft,
input: DecomposeDedupeInput,
): string {
const explicitName = draft.taskTypeName?.trim();
if (explicitName) return explicitName;
if (draft.categoryCode) {
return input.categories.find((category) => category.code === draft.categoryCode)?.name ?? draft.categoryCode;
}
return '';
} }
function buildFingerprint( function buildFingerprint(
title: string, title: string,
categoryCode: string, categoryName: string,
references: Array<AgentReference | Reference>, references: Array<AgentReference | Reference>,
input: DecomposeDedupeInput, input: DecomposeDedupeInput,
): string { ): string {
@@ -100,7 +112,7 @@ function buildFingerprint(
.filter(Boolean) .filter(Boolean)
.sort() .sort()
.join('|'); .join('|');
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`; return `${normalizeTitle(categoryName)}::${normalizeTitle(title)}::${refKey}`;
} }
function normalizeTitle(title: string): string { function normalizeTitle(title: string): string {
@@ -122,4 +134,3 @@ function normalizeReferenceKey(
} }
return `${ref.type}:${rawId.toLowerCase()}`; return `${ref.type}:${rawId.toLowerCase()}`;
} }

View File

@@ -11,6 +11,7 @@ function makeResult(): AgentDecomposeResult {
{ {
title: '实现登录表单', title: '实现登录表单',
description: '实现范围:渲染账号密码登录表单。验收点:输入合法信息后可提交。', description: '实现范围:渲染账号密码登录表单。验收点:输入合法信息后可提交。',
taskTypeName: '前端开发',
categoryCode: 'frontend_development', categoryCode: 'frontend_development',
priority: 'P2', priority: 'P2',
aiEstimateHours: 0.5, aiEstimateHours: 0.5,
@@ -21,6 +22,7 @@ function makeResult(): AgentDecomposeResult {
{ {
title: '验证登录成功', title: '验证登录成功',
description: '输入正确账号密码后进入首页', description: '输入正确账号密码后进入首页',
taskTypeName: '功能测试',
categoryCode: 'test_functional', categoryCode: 'test_functional',
priority: 'P2', priority: 'P2',
aiEstimateHours: 0.2, aiEstimateHours: 0.2,

View File

@@ -6,6 +6,7 @@ import {
PRESET_CATEGORIES, PRESET_CATEGORIES,
findCategoryByCode, findCategoryByCode,
getDefaultCategoryByGroup, getDefaultCategoryByGroup,
ensureTaskCategoryByName,
normalizeTaskCategories, normalizeTaskCategories,
resolveCategoryIdFromCode, resolveCategoryIdFromCode,
} from './task-category'; } from './task-category';
@@ -47,3 +48,23 @@ test('falls back to default group category for unknown code', () => {
test('finds category by code', () => { test('finds category by code', () => {
assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口'); assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口');
}); });
test('returns existing category when AI task type name already exists in same group', () => {
const result = ensureTaskCategoryByName(PRESET_CATEGORIES, ' 前端开发 ', 'development');
assert.equal(result.created, false);
assert.equal(result.category.id, 'cat-1');
assert.equal(result.categories.length, PRESET_CATEGORIES.length);
});
test('creates non-system category for missing AI task type name', () => {
const result = ensureTaskCategoryByName(PRESET_CATEGORIES, '人员名片交互', 'development');
assert.equal(result.created, true);
assert.equal(result.category.name, '人员名片交互');
assert.equal(result.category.group, 'development');
assert.equal(result.category.isSystem, false);
assert.equal(result.category.code, '人员名片交互');
assert.ok(result.category.id.startsWith('cat-ai-'));
assert.equal(result.categories.at(-1)?.id, result.category.id);
});

View File

@@ -61,6 +61,33 @@ function slugifyCategoryName(name: string, index: number): string {
); );
} }
function normalizeCategoryNameForMatch(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, '');
}
function hashCategoryName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
function uniqueCategoryCode(base: string, categories: TaskCategory[]): string {
if (!categories.some((category) => category.code === base)) return base;
let suffix = 2;
while (categories.some((category) => category.code === `${base}_${suffix}`)) suffix++;
return `${base}_${suffix}`;
}
function uniqueAiCategoryId(name: string, categories: TaskCategory[]): string {
const base = `cat-ai-${hashCategoryName(name)}-${categories.length + 1}`;
if (!categories.some((category) => category.id === base)) return base;
let suffix = 2;
while (categories.some((category) => category.id === `${base}-${suffix}`)) suffix++;
return `${base}-${suffix}`;
}
export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory { export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory {
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id); const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
const name = category?.name || preset?.name || `分类 ${index + 1}`; const name = category?.name || preset?.name || `分类 ${index + 1}`;
@@ -103,3 +130,43 @@ export function getDefaultCategoryByGroup(categories: TaskCategory[], group: Cat
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string { export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id; return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
} }
export function ensureTaskCategoryByName(
categories: TaskCategory[],
name: string | undefined,
group: CategoryGroup,
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
const normalizedCategories = normalizeTaskCategories(categories);
const trimmedName = name?.trim() ?? '';
if (!trimmedName) {
return {
categories: normalizedCategories,
category: getDefaultCategoryByGroup(normalizedCategories, group),
created: false,
};
}
const targetKey = normalizeCategoryNameForMatch(trimmedName);
const existing = normalizedCategories.find(
(category) => category.group === group && normalizeCategoryNameForMatch(category.name) === targetKey,
);
if (existing) {
return { categories: normalizedCategories, category: existing, created: false };
}
const codeBase = slugifyCategoryName(trimmedName, normalizedCategories.length);
const category: TaskCategory = {
id: uniqueAiCategoryId(trimmedName, normalizedCategories),
code: uniqueCategoryCode(codeBase, normalizedCategories),
name: trimmedName,
group,
sortOrder: Math.max(0, ...normalizedCategories.map((item) => item.sortOrder)) + 1,
isSystem: false,
};
return {
categories: [...normalizedCategories, category],
category,
created: true,
};
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category'; import { PRESET_CATEGORIES, ensureTaskCategoryByName, normalizeTaskCategories } from '@/lib/task-category';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
function saveStored(items: TaskCategory[]) { function saveStored(items: TaskCategory[]) {
@@ -19,6 +19,7 @@ interface TaskCategoryState {
categories: TaskCategory[]; categories: TaskCategory[];
fetchCategories: () => Promise<void>; fetchCategories: () => Promise<void>;
addCategory: (name: string, group: CategoryGroup, color?: string) => void; addCategory: (name: string, group: CategoryGroup, color?: string) => void;
ensureCategory: (name: string, group: CategoryGroup) => TaskCategory;
updateCategory: (id: string, data: Partial<TaskCategory>) => void; updateCategory: (id: string, data: Partial<TaskCategory>) => void;
deleteCategory: (id: string) => boolean; deleteCategory: (id: string) => boolean;
} }
@@ -47,6 +48,15 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
saveStored(updated); saveStored(updated);
}, },
ensureCategory: (name, group) => {
const result = ensureTaskCategoryByName(get().categories, name, group);
if (result.created) {
set({ categories: result.categories });
saveStored(result.categories);
}
return result.category;
},
updateCategory: (id, data) => { updateCategory: (id, data) => {
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c)); const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated }); set({ categories: updated });

View File

@@ -65,8 +65,8 @@
**输出** **输出**
- 对账报告(结构化文本,含"完美对应/需求未见原型/无需求ID分组/含糊" - 对账报告(结构化文本,含"完美对应/需求未见原型/无需求ID分组/含糊"
- DevTask 草案数组(每条带结构化 `description` + `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `recommendedAssigneeName` / `recommendedAssigneeReason` - DevTask 草案数组(每条带结构化 `description` + `taskTypeName` + `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `categoryCode` / `recommendedAssigneeName` / `recommendedAssigneeReason`
- TestCase 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `recommendedAssigneeName` / `recommendedAssigneeReason` - TestCase 草案数组(每条带 `taskTypeName` + `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `categoryCode` / `recommendedAssigneeName` / `recommendedAssigneeReason`
- `target = dev_tasks``testCaseDrafts` 必须为空数组;`target = test_cases``devTaskDrafts` 必须为空数组 - `target = dev_tasks``testCaseDrafts` 必须为空数组;`target = test_cases``devTaskDrafts` 必须为空数组
**原型与需求匹配规则** **原型与需求匹配规则**
@@ -89,11 +89,12 @@
- 不允许用一条“验证 XX 完整流程”覆盖多个交互、多个接口或多个规则。 - 不允许用一条“验证 XX 完整流程”覆盖多个交互、多个接口或多个规则。
- 一条 QY 若同时涉及 UI、接口、数据、异常和状态变化通常应拆出 3-8 条 TestCase。 - 一条 QY 若同时涉及 UI、接口、数据、异常和状态变化通常应拆出 3-8 条 TestCase。
- TestCase `description` 必填,至少包含前置条件、操作步骤和预期结果;涉及边界、异常或数据一致性时,必须写明测试数据或状态。 - TestCase `description` 必填,至少包含前置条件、操作步骤和预期结果;涉及边界、异常或数据一致性时,必须写明测试数据或状态。
- 测试用例 `categoryCode` 可使用:`test_functional``test_ui_interaction``test_form_validation``test_api``test_data_consistency``test_permission``test_exception``test_boundary``test_state_flow``test_compatibility``test_regression` - DevTask / TestCase 必须输出 `taskTypeName`,用于展示和任务类型字典写入。`categoryCode` 只作为可选的兼容映射字段;没有合适稳定码时可以省略,不能因此停止拆解
**写入** **写入**
- 用户确认后,调用 `useDevTaskStore.createTask``useTestCaseStore.createTestCase` - 用户确认后,调用 `useDevTaskStore.createTask``useTestCaseStore.createTestCase`
- 打开采纳弹窗前,前端先过滤当前版本已采纳过的重复 DevTask/TestCase 草案 - 打开采纳弹窗前,前端先过滤当前版本已采纳过的重复 DevTask/TestCase 草案
- 写入前,前端按 `taskTypeName` 检查 `TaskCategory` 字典;不存在时自动追加非系统任务类型,再用新 `categoryId` 写入 DevTask/TestCase
- 写入字段中 `aiDraft: true``aiDraftAt: ISO时间戳` - 写入字段中 `aiDraft: true``aiDraftAt: ISO时间戳`
- 写入 `aiEstimateHours`,不写入执行人预估 `estimateHours` - 写入 `aiEstimateHours`,不写入执行人预估 `estimateHours`
- DevTask / TestCase 必须写入 `versionId` 作为执行归属;`requirementId` 可选 - DevTask / TestCase 必须写入 `versionId` 作为执行归属;`requirementId` 可选
@@ -258,12 +259,12 @@ interface DecomposeOutput {
prototypeOnly: Array<{ requirementName: string; noteIds: string[]; taskCount: number }>; prototypeOnly: Array<{ requirementName: string; noteIds: string[]; taskCount: number }>;
ambiguous: Array<{ noteId: string; reason: string }>; ambiguous: Array<{ noteId: string; reason: string }>;
}; };
devTaskDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>; devTaskDrafts: Array<{ title: string; description: string; taskTypeName: string; categoryCode?: string; priority: Priority; aiEstimateHours: number; requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>;
testCaseDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>; testCaseDrafts: Array<{ title: string; description: string; taskTypeName: string; categoryCode?: string; priority: Priority; aiEstimateHours: number; requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>;
} }
``` ```
要求AI 不输出数据库 `categoryId`,只输出稳定 `categoryCode`。前端确认写入时按 `TaskCategory.code` 映射成 `categoryId`;映射失败时使用对应分组的默认类型兜底。AI 不输出 `estimateHours`、预计开始或预计截止。草案没有 `requirement` 引用时,必须有 `requirementName` 和至少一个 `prototype_note` 引用。 要求AI 不输出数据库 `categoryId``taskTypeName` 是必填的展示和字典类型名;`categoryCode` 只是可选映射提示,不限制 AI 拆解。前端确认写入时按 `taskTypeName` 自动确保 `TaskCategory` 存在,缺失则追加到任务类型字典,再写入对应 `categoryId`。AI 不输出 `estimateHours`、预计开始或预计截止。草案没有 `requirement` 引用时,必须有 `requirementName` 和至少一个 `prototype_note` 引用。
### 任务/用例分组契约 ### 任务/用例分组契约

View File

@@ -188,7 +188,7 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBACOwner/Admin/Member/
- `version-plan-workflow.ts`:调研/产品方案/UI 设计的子任务、需求覆盖、成果提交和完成条件。 - `version-plan-workflow.ts`:调研/产品方案/UI 设计的子任务、需求覆盖、成果提交和完成条件。
- `requirement-selector.ts`:当前版本所属项目下可关联需求的候选筛选,默认只返回 `status === 'adopted'` 的项目需求。 - `requirement-selector.ts`:当前版本所属项目下可关联需求的候选筛选,默认只返回 `status === 'adopted'` 的项目需求。
- `task-category.ts`DevTask/TestCase 共用任务类型字典,`id` 用于存储,`code` 用于 AI 语义映射。 - `task-category.ts`DevTask/TestCase 共用任务类型字典,`id` 用于存储,AI 输出的 `taskTypeName` 可在采纳时自动追加到字典,`code` 仅作可选语义映射。
页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。 页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。
## Work Activity Daily Report Layer (2026-06-26) ## Work Activity Daily Report Layer (2026-06-26)

View File

@@ -264,7 +264,7 @@
- 调研/产品方案/UI 设计都使用子任务清单;完成时必须满足规则引擎要求并提交链接或文件成果。 - 调研/产品方案/UI 设计都使用子任务清单;完成时必须满足规则引擎要求并提交链接或文件成果。
- 新增 `requirement-selector.ts`,所有关联需求候选都从当前版本所属项目下的已采纳需求中取,不从全量需求池取。 - 新增 `requirement-selector.ts`,所有关联需求候选都从当前版本所属项目下的已采纳需求中取,不从全量需求池取。
- DevTask 和 TestCase 共用 `TaskCategory` 字典,`TestCase` 增加 `categoryId` - DevTask 和 TestCase 共用 `TaskCategory` 字典,`TestCase` 增加 `categoryId`
- `TaskCategory` 增加稳定 `code`AI 输出 `categoryCode`,前端再映射为当前系统 `categoryId` - `TaskCategory` 增加稳定 `code`早期用于 AI `categoryCode`系统 `categoryId` 的映射;后续 AI 任务类型改为以 `taskTypeName` 为主,见决策 #41
**理由** **理由**
- 完成条件属于业务规则,不属于按钮组件;集中到工作流引擎后,列表和抽屉能保持一致。 - 完成条件属于业务规则,不属于按钮组件;集中到工作流引擎后,列表和抽屉能保持一致。
@@ -389,7 +389,7 @@
**决策** **决策**
- 在测试分组增加细分类UI 交互、表单校验、数据一致性、权限、边界值、状态流转、回归测试。 - 在测试分组增加细分类UI 交互、表单校验、数据一致性、权限、边界值、状态流转、回归测试。
- AI tool schema、shared 类型和前端任务类型字典同步允许这些 `categoryCode` - 早期 AI tool schema、shared 类型和前端任务类型字典同步允许这些 `categoryCode`后续这些类型只作为建议映射AI 可通过 `taskTypeName` 输出字典外类型,见决策 #41
- Prompt 明确要求 TestCase 按功能点、交互、接口、数据、权限、异常、边界、状态流转和回归点拆细。 - Prompt 明确要求 TestCase 按功能点、交互、接口、数据、权限、异常、边界、状态流转和回归点拆细。
- 一条 QY 如果同时涉及 UI、接口、数据、异常和状态变化通常应拆出 3-8 条 TestCase不允许用单条“完整流程验证”兜住。 - 一条 QY 如果同时涉及 UI、接口、数据、异常和状态变化通常应拆出 3-8 条 TestCase不允许用单条“完整流程验证”兜住。
@@ -503,3 +503,16 @@
- 原型批注只要包含标题、详细说明、字段、异常或验收标准之一,且能判断用户动作或系统行为,就不得进入 `ambiguous`;必须进入 `matched``prototypeOnly` 继续拆解。 - 原型批注只要包含标题、详细说明、字段、异常或验收标准之一,且能判断用户动作或系统行为,就不得进入 `ambiguous`;必须进入 `matched``prototypeOnly` 继续拆解。
**理由**Superpowers 的任务计划和 TDD 流程本质上是“先把工作拆成可验证切片,再用测试约束倒逼粒度”。把这套方法沉淀到 Agent 契约里,能减少粗拆、漏拆和误判含糊,同时不改变现有数据模型,只提高 AI 草案的结构质量。 **理由**Superpowers 的任务计划和 TDD 流程本质上是“先把工作拆成可验证切片,再用测试约束倒逼粒度”。把这套方法沉淀到 Agent 契约里,能减少粗拆、漏拆和误判含糊,同时不改变现有数据模型,只提高 AI 草案的结构质量。
## 41. AI 拆解任务类型不受人工字典限制,采纳时自动入库
**问题**任务类型字典最初服务于人工新建任务表单覆盖的是常见开发和测试类型。AI 从原型里识别出的真实交付切片可能更细,例如“人员名片交互”“预入职数据源”“姓名展示兼容测试”。如果继续要求 AI 只能输出既有 `categoryCode` 枚举,就会把拆解能力绑死在人工表单选项上,字典没覆盖时容易漏拆或被迫归到错误类型。
**决策**
- AI DevTask / TestCase 草案必须输出 `taskTypeName`,这是展示给用户的任务类型名称。
- `categoryCode` 改为可选兼容映射;只有能明确对应已有稳定语义码时才输出,不再作为 schema 必填项,也不再限制为固定枚举。
- 前端采纳 AI 结果时,按 `taskTypeName` 在当前 `TaskCategory` 同分组内查找;不存在则自动追加一条 `isSystem=false` 的任务类型,并用该类型的 `id` 写入 DevTask/TestCase。
- 去重逻辑按任务类型名称、标题和引用来源判断;已采纳过的自定义 AI 类型再次生成时也能过滤重复草案。
- 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。
**理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。`taskTypeName` 让模型按真实工作切片命名,采纳时自动补字典让后续筛选、统计和人工创建都能复用新类型,同时避免 AI 直接写数据库 `categoryId`

View File

@@ -235,7 +235,7 @@ AI 估时约束:
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。 - `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
- `requirement-selector.ts` 是版本内关联需求候选的唯一入口。 - `requirement-selector.ts` 是版本内关联需求候选的唯一入口。
- `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点`id` 只作为存储主键。 - `TaskCategory.name` 是 AI 草案的任务类型展示和自动入库锚点;`TaskCategory.code` 仅作为可选兼容映射`id` 只作为存储主键。
- DevTask 新数据必须有 `versionId``requirementId` 作为正式需求语义标签可选。版本级聚合走 `versionId`,需求级进度只统计带 `requirementId` 的任务。 - DevTask 新数据必须有 `versionId``requirementId` 作为正式需求语义标签可选。版本级聚合走 `versionId`,需求级进度只统计带 `requirementId` 的任务。
- 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。 - 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。
- 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。 - 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。

View File

@@ -35,7 +35,8 @@ export interface AgentReference {
export interface AgentDevTaskDraft { export interface AgentDevTaskDraft {
title: string; title: string;
description: string; description: string;
categoryCode: AgentTaskCategoryCode; taskTypeName: string;
categoryCode?: AgentTaskCategoryCode | string;
priority: 'P0' | 'P1' | 'P2' | 'P3'; priority: 'P0' | 'P1' | 'P2' | 'P3';
aiEstimateHours: number; aiEstimateHours: number;
requirementName?: string; requirementName?: string;
@@ -47,7 +48,8 @@ export interface AgentDevTaskDraft {
export interface AgentTestCaseDraft { export interface AgentTestCaseDraft {
title: string; title: string;
description: string; description: string;
categoryCode: AgentTaskCategoryCode; taskTypeName: string;
categoryCode?: AgentTaskCategoryCode | string;
priority: 'P0' | 'P1' | 'P2' | 'P3'; priority: 'P0' | 'P1' | 'P2' | 'P3';
aiEstimateHours: number; aiEstimateHours: number;
requirementName?: string; requirementName?: string;