feat(ai): 优化拆解目标和负责人推荐

This commit is contained in:
Script Generator
2026-06-29 16:19:52 +08:00
parent 0e27a4d4c5
commit 7f24e16491
19 changed files with 658 additions and 118 deletions

View 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);
});

View 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 } : {}),
};
}

View 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);
});

View 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;
}

View File

@@ -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);
});

View File

@@ -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 {

View File

@@ -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));
});

View File

@@ -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 },

View File

@@ -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;
}