feat(ai): 优化拆解目标和负责人推荐
This commit is contained in:
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