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

@@ -8,7 +8,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
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 { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption';
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
@@ -45,7 +45,7 @@ export function DecomposeReportModal({
}: Props) {
const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore();
const { categories } = useTaskCategoryStore();
const { categories, ensureCategory } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const { result: data, meta } = result;
@@ -90,6 +90,18 @@ export function DecomposeReportModal({
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 = () => {
if (submitting) return;
setSubmitting(true);
@@ -111,7 +123,8 @@ export function DecomposeReportModal({
const draft = data.devTaskDrafts[i];
const refs = normalizeRefs(draft.references);
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 recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -152,7 +165,8 @@ export function DecomposeReportModal({
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
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 recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -337,7 +351,7 @@ export function DecomposeReportModal({
<div className="flex items-center gap-2 flex-wrap">
<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)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
{resolveDraftTaskTypeName(d, 'development')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
@@ -408,7 +422,7 @@ export function DecomposeReportModal({
<div className="flex items-center gap-2 flex-wrap">
<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)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
{resolveDraftTaskTypeName(d, 'testing')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}

View File

@@ -56,6 +56,7 @@ test('filters dev task drafts already adopted into existing tasks', () => {
{
title: '实现拖拽排序',
description: '实现范围:支持列表拖拽调整顺序。验收点:拖拽后顺序保持一致。',
taskTypeName: '前端交互',
categoryCode: 'frontend_interaction',
priority: 'P2',
aiEstimateHours: 0.25,
@@ -92,6 +93,7 @@ test('filters test case drafts already adopted into existing test cases', () =>
{
title: '验证拖拽排序成功',
description: '拖拽后顺序保存',
taskTypeName: '功能测试',
categoryCode: 'test_functional',
priority: 'P2',
aiEstimateHours: 0.2,
@@ -125,6 +127,7 @@ test('keeps drafts with same references but different normalized title', () => {
{
title: '保存拖拽排序结果',
description: '实现范围:保存拖拽后的排序结果。验收点:刷新后顺序不丢失。',
taskTypeName: '前端交互',
categoryCode: 'frontend_interaction',
priority: 'P2',
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.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,
input: DecomposeDedupeInput,
): string {
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
return buildFingerprint(draft.title, draftCategoryIdentity(draft, input), draft.references, input);
}
function existingItemFingerprint(
@@ -85,13 +85,25 @@ function existingItemFingerprint(
references: Reference[],
input: DecomposeDedupeInput,
): string {
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
return buildFingerprint(title, categoryCode, references, input);
const categoryName = input.categories.find((category) => category.id === categoryId)?.name ?? categoryId;
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(
title: string,
categoryCode: string,
categoryName: string,
references: Array<AgentReference | Reference>,
input: DecomposeDedupeInput,
): string {
@@ -100,7 +112,7 @@ function buildFingerprint(
.filter(Boolean)
.sort()
.join('|');
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
return `${normalizeTitle(categoryName)}::${normalizeTitle(title)}::${refKey}`;
}
function normalizeTitle(title: string): string {
@@ -122,4 +134,3 @@ function normalizeReferenceKey(
}
return `${ref.type}:${rawId.toLowerCase()}`;
}

View File

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

View File

@@ -6,6 +6,7 @@ import {
PRESET_CATEGORIES,
findCategoryByCode,
getDefaultCategoryByGroup,
ensureTaskCategoryByName,
normalizeTaskCategories,
resolveCategoryIdFromCode,
} from './task-category';
@@ -47,3 +48,23 @@ test('falls back to default group category for unknown code', () => {
test('finds category by code', () => {
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 {
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
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 {
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';
import { create } from 'zustand';
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';
function saveStored(items: TaskCategory[]) {
@@ -19,6 +19,7 @@ interface TaskCategoryState {
categories: TaskCategory[];
fetchCategories: () => Promise<void>;
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
ensureCategory: (name: string, group: CategoryGroup) => TaskCategory;
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
deleteCategory: (id: string) => boolean;
}
@@ -47,6 +48,15 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
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) => {
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated });