fix(ai): 收紧AI任务类型入库规则

This commit is contained in:
Script Generator
2026-07-02 18:19:34 +08:00
parent a39dde3824
commit 68b768788b
10 changed files with 90 additions and 22 deletions

View File

@@ -423,6 +423,8 @@ describe('AiService', () => {
expect(devCategoryCodeSchema).toEqual({ type: 'string' });
expect(testCaseCategoryCodeSchema).toEqual({ type: 'string' });
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('taskTypeName');
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('taskTypeName 是任务类型字典项,不是任务标题或任务概述');
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('必须可复用');
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('categoryCode 只作为可选映射');
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('不得因为任务类型字典没有覆盖就不拆解');
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('采纳时自动追加到任务类型字典');

View File

@@ -56,12 +56,14 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
- 测试用例 description 必须写成结构化短文本,至少包含:前置条件、操作步骤、预期结果;涉及边界/异常/数据时要写明测试数据或状态
5. 任务类型
- 每条开发任务和测试用例都必须输出 taskTypeName,使用中文业务类型名,例如"人员名片交互"、"预入职数据源"、"姓名展示兼容测试"
- 每条开发任务和测试用例都必须输出 taskTypeName
- taskTypeName 是任务类型字典项,不是任务标题或任务概述;必须可复用,不能写成当前文档里的业务对象或场景名称
- 正确示例:前端开发、前端交互、后端接口、数据库设计、数据处理、功能测试、接口测试、数据一致性测试、权限测试、状态流转测试、异常场景测试
- 错误示例:排行榜测试、人员名片识别交互、预入职人员选择展示、姓名展示兼容测试;这些应写进 title 或 description
- categoryCode 只作为可选映射;只有能明确映射到已有稳定语义码时才输出
- 不得因为任务类型字典没有覆盖就不拆解;如果没有合适 categoryCode省略 categoryCode但必须保留准确的 taskTypeName
- 采纳时自动追加到任务类型字典AI 不需要也不得输出数据库 categoryId
- 开发任务建议类型:前端开发、前端交互、后端开发、后端接口、数据库设计、接口联调、数据处理、实施支持、文档,或按原型识别出更准确的类型名
- 测试用例建议类型功能测试、UI交互测试、表单校验测试、接口测试、数据一致性测试、权限测试、异常场景测试、边界值测试、状态流转测试、兼容性测试、回归测试或按验收点识别出更准确的类型名
- 采纳时自动追加到任务类型字典仅限可跨需求复用的新增类型;不要为单个原型或单条 QY 造一次性类型
- 测试用例优先使用测试用例建议类型功能测试、UI交互测试、表单校验测试、接口测试、数据一致性测试、权限测试、异常场景测试、边界值测试、状态流转测试、兼容性测试、回归测试
- 不输出数据库 categoryId
6. 推荐负责人(可选字段)

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, type CategoryGroup } from '@/lib/task-category';
import { findCategoryByCode, resolveAiTaskCategoryByName, 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';
@@ -102,6 +102,15 @@ export function DecomposeReportModal({
return fallbackGroup === 'testing' ? '功能测试' : '开发任务';
};
const resolveDraftCategoryName = (
draft: AgentDevTaskDraft | AgentTestCaseDraft,
fallbackGroup: Extract<CategoryGroup, 'development' | 'testing'>,
) =>
resolveAiTaskCategoryByName(categories, resolveDraftTaskTypeName(draft, fallbackGroup), fallbackGroup, {
allowCreate: fallbackGroup !== 'testing',
fallbackCode: draft.categoryCode,
}).category.name;
const handleAdopt = () => {
if (submitting) return;
setSubmitting(true);
@@ -124,7 +133,7 @@ export function DecomposeReportModal({
const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const taskTypeName = resolveDraftTaskTypeName(draft, 'development');
const categoryId = ensureCategory(taskTypeName, 'development').id;
const categoryId = ensureCategory(taskTypeName, 'development', { fallbackCode: draft.categoryCode }).id;
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -166,7 +175,10 @@ export function DecomposeReportModal({
const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const taskTypeName = resolveDraftTaskTypeName(draft, 'testing');
const categoryId = ensureCategory(taskTypeName, 'testing').id;
const categoryId = ensureCategory(taskTypeName, 'testing', {
allowCreate: false,
fallbackCode: draft.categoryCode,
}).id;
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
@@ -351,7 +363,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)]">
{resolveDraftTaskTypeName(d, 'development')}
{resolveDraftCategoryName(d, 'development')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
@@ -422,7 +434,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)]">
{resolveDraftTaskTypeName(d, 'testing')}
{resolveDraftCategoryName(d, 'testing')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}

View File

@@ -8,6 +8,7 @@ import {
getDefaultCategoryByGroup,
ensureTaskCategoryByName,
normalizeTaskCategories,
resolveAiTaskCategoryByName,
resolveCategoryIdFromCode,
} from './task-category';
@@ -68,3 +69,25 @@ test('creates non-system category for missing AI task type name', () => {
assert.ok(result.category.id.startsWith('cat-ai-'));
assert.equal(result.categories.at(-1)?.id, result.category.id);
});
test('does not create document-specific testing type when auto creation is disabled', () => {
const result = resolveAiTaskCategoryByName(PRESET_CATEGORIES, '排行榜测试', 'testing', {
allowCreate: false,
fallbackCode: 'test_data_consistency',
});
assert.equal(result.created, false);
assert.equal(result.category.code, 'test_data_consistency');
assert.equal(result.categories.length, PRESET_CATEGORIES.length);
});
test('keeps testing fallback within the testing category group', () => {
const result = resolveAiTaskCategoryByName(PRESET_CATEGORIES, '排行榜测试', 'testing', {
allowCreate: false,
fallbackCode: 'frontend_development',
});
assert.equal(result.created, false);
assert.equal(result.category.id, DEFAULT_TEST_CATEGORY_ID);
assert.equal(result.category.group, 'testing');
});

View File

@@ -123,6 +123,15 @@ export function findCategoryByCode(categories: TaskCategory[], code?: string): T
return categories.find((c) => c.code === code);
}
function findCategoryByCodeInGroup(
categories: TaskCategory[],
code: string | undefined,
group: CategoryGroup,
): TaskCategory | undefined {
if (!code) return undefined;
return categories.find((category) => category.group === group && category.code === code);
}
export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
}
@@ -135,13 +144,24 @@ export function ensureTaskCategoryByName(
categories: TaskCategory[],
name: string | undefined,
group: CategoryGroup,
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
return resolveAiTaskCategoryByName(categories, name, group, { allowCreate: true });
}
export function resolveAiTaskCategoryByName(
categories: TaskCategory[],
name: string | undefined,
group: CategoryGroup,
options: { allowCreate?: boolean; fallbackCode?: string } = {},
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
const normalizedCategories = normalizeTaskCategories(categories);
const trimmedName = name?.trim() ?? '';
if (!trimmedName) {
return {
categories: normalizedCategories,
category: getDefaultCategoryByGroup(normalizedCategories, group),
category:
findCategoryByCodeInGroup(normalizedCategories, options.fallbackCode, group) ??
getDefaultCategoryByGroup(normalizedCategories, group),
created: false,
};
}
@@ -153,6 +173,15 @@ export function ensureTaskCategoryByName(
if (existing) {
return { categories: normalizedCategories, category: existing, created: false };
}
if (options.allowCreate === false) {
return {
categories: normalizedCategories,
category:
findCategoryByCodeInGroup(normalizedCategories, options.fallbackCode, group) ??
getDefaultCategoryByGroup(normalizedCategories, group),
created: false,
};
}
const codeBase = slugifyCategoryName(trimmedName, normalizedCategories.length);
const category: TaskCategory = {

View File

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