feat(ai): 优化拆解目标和负责人推荐
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { AiService } from './ai.service';
|
||||
import type { AiGatewayService } from './ai-gateway.service';
|
||||
import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
|
||||
import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
|
||||
|
||||
const missingToolUseMessage =
|
||||
'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
|
||||
@@ -147,6 +147,43 @@ describe('AiService', () => {
|
||||
expect(testCaseRequired).toContain('categoryCode');
|
||||
});
|
||||
|
||||
it('allows fine-grained test case category codes for detailed AI decomposition', () => {
|
||||
const testCaseCategoryEnum = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.properties
|
||||
.categoryCode.enum;
|
||||
|
||||
expect(testCaseCategoryEnum).toEqual(
|
||||
expect.arrayContaining([
|
||||
'test_ui_interaction',
|
||||
'test_form_validation',
|
||||
'test_data_consistency',
|
||||
'test_permission',
|
||||
'test_boundary',
|
||||
'test_state_flow',
|
||||
'test_regression',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows optional recommended assignee fields without requiring adoption', () => {
|
||||
const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items;
|
||||
const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items;
|
||||
|
||||
expect(devSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' });
|
||||
expect(devSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' });
|
||||
expect(testCaseSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' });
|
||||
expect(testCaseSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' });
|
||||
expect(devSchema.required).not.toContain('recommendedAssigneeName');
|
||||
expect(devSchema.required).not.toContain('recommendedAssigneeReason');
|
||||
expect(testCaseSchema.required).not.toContain('recommendedAssigneeName');
|
||||
expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason');
|
||||
});
|
||||
|
||||
it('tells the model to recommend assignees only from version member names', () => {
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('recommendedAssigneeName');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('members[].name');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('omit recommendedAssigneeName');
|
||||
});
|
||||
|
||||
it('requires aiEstimateHours for dev task and test case drafts', () => {
|
||||
const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
|
||||
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
|
||||
@@ -191,4 +228,46 @@ describe('AiService', () => {
|
||||
expect(userPrompt).toContain('本次只重新拆解开发任务');
|
||||
expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
|
||||
});
|
||||
|
||||
it('tells the model to match prototype notes by requirement code before summary semantics', async () => {
|
||||
const callTool = jest.fn().mockResolvedValue({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
rawModel: 'claude-test',
|
||||
});
|
||||
|
||||
const gateway = {
|
||||
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
|
||||
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
|
||||
} as unknown as AiGatewayService;
|
||||
|
||||
const service = new AiService(gateway);
|
||||
(service as any).fetchPrototype = jest.fn().mockResolvedValue('REA-20260629001:客户批量导入');
|
||||
|
||||
await service.decompose({
|
||||
prototypeUrl: 'https://example.com/prototype',
|
||||
requirements: [
|
||||
{
|
||||
id: 'req-1',
|
||||
code: 'REA-20260629001',
|
||||
title: '客户批量导入',
|
||||
description: '上传 Excel 后生成客户档案',
|
||||
},
|
||||
],
|
||||
members: [{ name: '张三', role: 'frontend' }],
|
||||
versionId: 'version-1',
|
||||
planId: 'plan-1',
|
||||
target: 'dev_tasks',
|
||||
});
|
||||
|
||||
const userPrompt = callTool.mock.calls[0][0].userPrompt;
|
||||
expect(userPrompt).toContain('优先按需求编号');
|
||||
expect(userPrompt).toContain('需求概述');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class AiService {
|
||||
let toolResp;
|
||||
for (let attemptIndex = 0; attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length; attemptIndex++) {
|
||||
const contextChars = DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex];
|
||||
const prototypeContext = buildPrototypeContext(prototypeContent, contextChars);
|
||||
const prototypeContext = buildPrototypeContext(prototypeContent, contextChars, { requirements: req.requirements });
|
||||
const userPrompt = this.buildUserPrompt(req, prototypeContext.text, prototypeContext, attemptIndex > 0);
|
||||
|
||||
try {
|
||||
@@ -202,7 +202,7 @@ export class AiService {
|
||||
|
||||
URL: ${req.prototypeUrl}
|
||||
|
||||
### 原型内容(已提取 QY 批注相关片段)
|
||||
### 原型内容(已提取 QY 批注和需求命中相关片段)
|
||||
|
||||
- 识别到 QY 批注数量:${context?.noteCount ?? 0}
|
||||
- 内容已截断:${context?.truncated ? '是' : '否'}
|
||||
@@ -213,10 +213,19 @@ ${prototypeContent}
|
||||
|
||||
${reqList || '(无)'}
|
||||
|
||||
## 原型与需求匹配优先级
|
||||
|
||||
1. 优先按需求编号匹配:原型批注或文本中出现 requirement.id 或 requirement.code 时,必须优先判定为该需求命中。
|
||||
2. 如果没有出现需求编号,再按需求概述语义匹配:用需求 title 和 description 与原型批注/文本表达做对应。
|
||||
3. 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号;没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃该需求,可只使用 requirement 引用并在 matched.noteIds 返回空数组。
|
||||
4. 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous。
|
||||
|
||||
## 版本成员
|
||||
|
||||
${memberList || '(无)'}
|
||||
|
||||
负责人推荐只能使用上方版本成员的 members[].name;没有明确匹配时不要输出 recommendedAssigneeName。
|
||||
|
||||
## 本次拆解目标
|
||||
|
||||
${targetInstruction}
|
||||
|
||||
@@ -19,26 +19,41 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
||||
- 引用 prototype_note 必须是输入原型里真实存在的 QY 编号
|
||||
- 不得编造
|
||||
|
||||
2. 任务来源限定
|
||||
2. 原型与需求匹配优先级
|
||||
- 第一优先级:原型批注或文本里直接出现 requirement.id 或 requirement.code(需求编号)时,必须判定为该需求命中
|
||||
- 第二优先级:没有需求编号时,再用需求 title / description(需求概述)与原型批注或文本做语义匹配
|
||||
- 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号
|
||||
- 没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃;可只引用 requirement,并在 matched.noteIds 返回空数组
|
||||
- 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous
|
||||
|
||||
3. 任务来源限定
|
||||
- 只为以下情况拆任务:
|
||||
a) 同时被需求和原型 QY 命中
|
||||
b) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
|
||||
b) 需求编号或需求概述在原型文本中命中,但没有 QY 编号
|
||||
c) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
|
||||
- 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出
|
||||
|
||||
3. 颗粒度(细颗粒)
|
||||
4. 颗粒度(细颗粒)
|
||||
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
|
||||
- 接口、数据库改动、前端 UI、前端交互、表单校验视为独立任务
|
||||
- 一条 QY 可能产出 3-6 个 DevTask
|
||||
- 测试用例:每条 QY 至少 1 条功能用例 + 1 条边界用例
|
||||
- 测试用例粒度要和开发任务一样细:每个明确功能点、UI 交互、表单校验、接口、数据保存、权限、状态流转、异常、边界、兼容性或回归点都应拆成独立 TestCase
|
||||
- 不要用一条"验证 XX 完整流程"覆盖多个交互或多个规则
|
||||
- 一条 QY 若同时涉及 UI、接口、数据、异常和状态变化,通常应拆出 3-8 个 TestCase
|
||||
|
||||
4. 任务类型
|
||||
5. 任务类型
|
||||
- 每条开发任务和测试用例都必须输出 categoryCode
|
||||
- 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration
|
||||
- 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility
|
||||
- 测试用例优先使用 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
|
||||
- 不输出数据库 categoryId
|
||||
- 不输出推荐负责人(用户后续手填)
|
||||
|
||||
5. AI 工时估算(字段名 aiEstimateHours,单位小时)
|
||||
6. 推荐负责人(可选字段)
|
||||
- 可以输出 recommendedAssigneeName 和 recommendedAssigneeReason,但 recommendedAssigneeName 只能从输入版本成员清单的 members[].name 中精确选择
|
||||
- 开发任务按 categoryCode 优先匹配 frontend/backend 角色;测试用例优先匹配 testing 角色
|
||||
- 如果没有明确匹配的版本成员,omit recommendedAssigneeName and recommendedAssigneeReason
|
||||
- 不要把推荐当作已分配,最终是否采纳由用户确认
|
||||
|
||||
7. AI 工时估算(字段名 aiEstimateHours,单位小时)
|
||||
- aiEstimateHours 只代表 AI 对工作量的判断,不代表负责人计划排期
|
||||
- 不要输出 estimateHours、预计开始时间、预计截止时间
|
||||
- 简单前端字段、文案、展示调整: 0.25-0.5h
|
||||
@@ -47,16 +62,17 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
||||
- 简单 CRUD 接口: 0.5-1h
|
||||
- 数据库字段/索引调整: 0.25-0.5h
|
||||
- 中等业务规则变更: 1-2h
|
||||
- 简单功能测试用例执行: 0.1-0.3h
|
||||
- API/异常测试用例执行: 0.2-0.5h
|
||||
- 简单功能/UI交互/表单校验/边界测试用例执行: 0.1-0.3h
|
||||
- API/异常/数据一致性/权限/状态流转测试用例执行: 0.2-0.5h
|
||||
- 回归测试用例执行: 0.15-0.4h
|
||||
- 兼容性测试用例执行: 0.3-0.75h
|
||||
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
|
||||
|
||||
6. 标题:中文动词开头,简洁
|
||||
8. 标题:中文动词开头,简洁
|
||||
✓ "在主题列表实现拖拽排序"
|
||||
✗ "关于 QY0010 主题拖拽排序的优化方案研究与实现"
|
||||
|
||||
7. 不凭空补
|
||||
9. 不凭空补
|
||||
- 不要因为"通常应该有"就加"权限校验"任务
|
||||
- 只拆需求和原型上明确存在的内容
|
||||
|
||||
@@ -67,7 +83,8 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
||||
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
|
||||
|
||||
【特殊情况】
|
||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组
|
||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,但能匹配到需求编号或需求概述 → 仍按命中的需求拆解,matched.noteIds 返回空数组
|
||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,也匹配不到任何需求编号或需求概述 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组
|
||||
- 若原型完全无法解析 → 同上处理
|
||||
|
||||
【输出通道强制要求】
|
||||
@@ -137,6 +154,8 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
},
|
||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||
aiEstimateHours: { type: 'number' },
|
||||
recommendedAssigneeName: { type: 'string' },
|
||||
recommendedAssigneeReason: { type: 'string' },
|
||||
references: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@@ -163,10 +182,24 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
description: { type: 'string' },
|
||||
categoryCode: {
|
||||
type: 'string',
|
||||
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
|
||||
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'] },
|
||||
aiEstimateHours: { type: 'number' },
|
||||
recommendedAssigneeName: { type: 'string' },
|
||||
recommendedAssigneeReason: { type: 'string' },
|
||||
references: {
|
||||
type: 'array',
|
||||
items: {
|
||||
|
||||
@@ -24,4 +24,28 @@ describe('buildPrototypeContext', () => {
|
||||
expect(context.text).toContain('QY0002');
|
||||
expect(context.text.length).toBeLessThan(800);
|
||||
});
|
||||
|
||||
it('keeps snippets that match requirement code or summary even when they are far from QY notes', () => {
|
||||
const content = [
|
||||
`${'页面噪声'.repeat(600)} QY0001:登录按钮颜色改为蓝色。${'无关内容'.repeat(600)}`,
|
||||
'REA-20260629001:客户批量导入,支持上传 Excel 并生成客户档案。',
|
||||
`${'更多噪声'.repeat(600)}`,
|
||||
].join(' ');
|
||||
|
||||
const context = buildPrototypeContext(content, 900, {
|
||||
requirements: [
|
||||
{
|
||||
id: 'req-1',
|
||||
code: 'REA-20260629001',
|
||||
title: '客户批量导入',
|
||||
description: '上传 Excel 后生成客户档案',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(context.text).toContain('QY0001');
|
||||
expect(context.text).toContain('REA-20260629001');
|
||||
expect(context.text).toContain('客户批量导入');
|
||||
expect(context.text).not.toContain('更多噪声'.repeat(20));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,32 @@ export interface PrototypeContext {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface PrototypeContextRequirement {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PrototypeContextOptions {
|
||||
requirements?: PrototypeContextRequirement[];
|
||||
}
|
||||
|
||||
const QY_NOTE_PATTERN = /QY\d{3,}/gi;
|
||||
const MAX_NOTE_SNIPPETS = 40;
|
||||
const MAX_REQUIREMENT_SNIPPETS = 30;
|
||||
type PrototypeAnchor = { index: number; kind: 'note' | 'requirement' };
|
||||
|
||||
export function buildPrototypeContext(content: string, maxChars = 12000): PrototypeContext {
|
||||
export function buildPrototypeContext(
|
||||
content: string,
|
||||
maxChars = 12000,
|
||||
options: PrototypeContextOptions = {},
|
||||
): PrototypeContext {
|
||||
const normalized = content.replace(/\s+/g, ' ').trim();
|
||||
const matches = Array.from(normalized.matchAll(QY_NOTE_PATTERN));
|
||||
const requirementAnchors = findRequirementAnchors(normalized, options.requirements ?? []);
|
||||
|
||||
if (matches.length === 0) {
|
||||
if (matches.length === 0 && requirementAnchors.length === 0) {
|
||||
return {
|
||||
text: normalized.slice(0, maxChars),
|
||||
noteCount: 0,
|
||||
@@ -19,13 +37,17 @@ export function buildPrototypeContext(content: string, maxChars = 12000): Protot
|
||||
};
|
||||
}
|
||||
|
||||
const windowSize = Math.max(280, Math.floor(maxChars / Math.min(matches.length, 12)));
|
||||
const ranges = matches.slice(0, MAX_NOTE_SNIPPETS)
|
||||
.map((match) => {
|
||||
const index = match.index ?? 0;
|
||||
const anchors: PrototypeAnchor[] = [
|
||||
...matches.slice(0, MAX_NOTE_SNIPPETS).map((match) => ({ index: match.index ?? 0, kind: 'note' as const })),
|
||||
...requirementAnchors.slice(0, MAX_REQUIREMENT_SNIPPETS).map((index) => ({ index, kind: 'requirement' as const })),
|
||||
].sort((a, b) => a.index - b.index);
|
||||
|
||||
const windowSize = Math.max(280, Math.floor(maxChars / Math.min(anchors.length, 12)));
|
||||
const ranges = anchors
|
||||
.map((anchor) => {
|
||||
return {
|
||||
start: Math.max(0, index - 120),
|
||||
end: Math.min(normalized.length, index + windowSize),
|
||||
start: Math.max(0, anchor.index - 120),
|
||||
end: getAnchorRangeEnd(normalized, anchor, windowSize),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.start - b.start);
|
||||
@@ -70,3 +92,60 @@ export function buildPrototypeContext(content: string, maxChars = 12000): Protot
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function findRequirementAnchors(content: string, requirements: PrototypeContextRequirement[]): number[] {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const anchors = new Set<number>();
|
||||
|
||||
for (const requirement of requirements) {
|
||||
for (const term of buildRequirementMatchTerms(requirement)) {
|
||||
const lowerTerm = term.toLowerCase();
|
||||
let cursor = 0;
|
||||
while (cursor < lowerContent.length) {
|
||||
const index = lowerContent.indexOf(lowerTerm, cursor);
|
||||
if (index < 0) break;
|
||||
anchors.add(index);
|
||||
cursor = index + Math.max(lowerTerm.length, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...anchors].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function buildRequirementMatchTerms(requirement: PrototypeContextRequirement): string[] {
|
||||
const terms = new Set<string>();
|
||||
addMatchTerm(terms, requirement.id);
|
||||
addMatchTerm(terms, requirement.code);
|
||||
addMatchTerm(terms, requirement.title);
|
||||
|
||||
for (const part of (requirement.description ?? '').split(/[,,。.;;、\n\r\t]+/)) {
|
||||
addMatchTerm(terms, part);
|
||||
}
|
||||
|
||||
return [...terms];
|
||||
}
|
||||
|
||||
function addMatchTerm(terms: Set<string>, value?: string): void {
|
||||
const term = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (term.length < 2) return;
|
||||
terms.add(term);
|
||||
}
|
||||
|
||||
function getAnchorRangeEnd(content: string, anchor: PrototypeAnchor, windowSize: number): number {
|
||||
const fallbackEnd = Math.min(content.length, anchor.index + windowSize);
|
||||
if (anchor.kind === 'note') return fallbackEnd;
|
||||
|
||||
const sentenceEnd = findSentenceEnd(content, anchor.index, Math.min(windowSize, 320));
|
||||
return sentenceEnd ?? Math.min(content.length, anchor.index + Math.min(windowSize, 220));
|
||||
}
|
||||
|
||||
function findSentenceEnd(content: string, start: number, maxDistance: number): number | null {
|
||||
const limit = Math.min(content.length, start + maxDistance);
|
||||
for (let i = start; i < limit; i++) {
|
||||
if ('。;;!?!?'.includes(content[i])) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
|
||||
import { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target';
|
||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
@@ -25,6 +26,12 @@ interface Props {
|
||||
version: VersionWithContext;
|
||||
}
|
||||
|
||||
interface DecomposeModalState {
|
||||
result: AgentDecomposeResponse;
|
||||
target: AgentDecomposeTarget;
|
||||
dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number };
|
||||
}
|
||||
|
||||
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
|
||||
const STUCK_THRESHOLD_SEC = 240;
|
||||
|
||||
@@ -44,9 +51,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
|
||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
|
||||
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
|
||||
const [modalState, setModalState] = useState<DecomposeModalState | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const loading = activeTarget !== null;
|
||||
@@ -55,6 +60,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const persistStatus = plan.aiDecomposeStatus;
|
||||
const persistError = plan.aiDecomposeError;
|
||||
const persistAt = plan.aiDecomposeAt;
|
||||
const persistTarget = plan.aiDecomposeTarget;
|
||||
|
||||
// 计算"已用时"
|
||||
const elapsedSec = (() => {
|
||||
@@ -72,6 +78,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
|
||||
const isError = persistStatus === 'error' && !loading;
|
||||
const wasCompleted = persistStatus === 'completed' && !loading;
|
||||
const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null);
|
||||
|
||||
// tick 每秒更新一次,让"已用时"实时跳
|
||||
useEffect(() => {
|
||||
@@ -108,6 +115,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'in_progress',
|
||||
aiDecomposeBy: user?.name,
|
||||
aiDecomposeAt: new Date().toISOString(),
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
|
||||
@@ -134,24 +142,29 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
if (!resp.ok) {
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: resp.error,
|
||||
});
|
||||
} else {
|
||||
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
||||
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
||||
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
|
||||
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
|
||||
existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
|
||||
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
||||
categories,
|
||||
requirements: linkedReqs,
|
||||
});
|
||||
setResult({ ...resp, result: deduped.result });
|
||||
setDedupeSummary({
|
||||
setModalState({
|
||||
result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) },
|
||||
target,
|
||||
dedupeSummary: {
|
||||
removedDevTaskCount: deduped.removedDevTaskCount,
|
||||
removedTestCaseCount: deduped.removedTestCaseCount,
|
||||
},
|
||||
});
|
||||
setResultTarget(target);
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
}
|
||||
@@ -159,6 +172,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const msg = e?.message || '调用 AI 服务失败';
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: msg,
|
||||
});
|
||||
} finally {
|
||||
@@ -175,10 +189,11 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
|
||||
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
|
||||
const showSpinner = isInProgress && runningTarget === target;
|
||||
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
onClick={() => handleClick(target)}
|
||||
disabled={isInProgress}
|
||||
@@ -231,15 +246,15 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
{modalState && (
|
||||
<DecomposeReportModal
|
||||
result={result}
|
||||
result={modalState.result}
|
||||
version={version}
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
target={resultTarget}
|
||||
dedupeSummary={dedupeSummary}
|
||||
onClose={() => setResult(null)}
|
||||
target={modalState.target}
|
||||
dedupeSummary={modalState.dedupeSummary}
|
||||
onClose={() => setModalState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||||
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
||||
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
AgentDecomposeResponse,
|
||||
@@ -41,11 +42,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
|
||||
|
||||
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||
new Set(data.devTaskDrafts.map((_, i) => i)),
|
||||
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
|
||||
);
|
||||
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
|
||||
new Set(data.testCaseDrafts.map((_, i) => i)),
|
||||
new Set(showTestDrafts ? data.testCaseDrafts.map((_, i) => i) : []),
|
||||
);
|
||||
const [useRecommendedAssignees, setUseRecommendedAssignees] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -89,7 +91,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
return ref;
|
||||
});
|
||||
|
||||
let devCount = 0;
|
||||
if (showDevDrafts) {
|
||||
for (let i = 0; i < data.devTaskDrafts.length; i++) {
|
||||
if (!selectedDevIdx.has(i)) continue;
|
||||
const draft = data.devTaskDrafts[i];
|
||||
@@ -98,13 +100,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||
|
||||
createTask({
|
||||
requirementId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
assigneeId: '',
|
||||
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name ?? '' : '',
|
||||
reviewerId: undefined,
|
||||
priority: draft.priority,
|
||||
expectedStartAt: '',
|
||||
@@ -125,10 +128,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
devCount++;
|
||||
}
|
||||
}
|
||||
|
||||
let tcCount = 0;
|
||||
if (showTestDrafts) {
|
||||
for (let i = 0; i < data.testCaseDrafts.length; i++) {
|
||||
if (!selectedTcIdx.has(i)) continue;
|
||||
const draft = data.testCaseDrafts[i];
|
||||
@@ -136,6 +139,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||
|
||||
createTestCase({
|
||||
versionId: version.id,
|
||||
@@ -146,13 +150,13 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
priority: draft.priority,
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
assigneeId: undefined,
|
||||
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name : undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
tcCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
@@ -162,6 +166,17 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
setTimeout(() => onClose(), 1500);
|
||||
};
|
||||
|
||||
const visibleRecommendationCount = useMemo(() => {
|
||||
const members = version.members ?? [];
|
||||
const devCount = showDevDrafts
|
||||
? data.devTaskDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
|
||||
: 0;
|
||||
const testCount = showTestDrafts
|
||||
? data.testCaseDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
|
||||
: 0;
|
||||
return devCount + testCount;
|
||||
}, [data.devTaskDrafts, data.testCaseDrafts, showDevDrafts, showTestDrafts, version.members]);
|
||||
|
||||
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
|
||||
|
||||
return (
|
||||
@@ -281,7 +296,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的任务</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.devTaskDrafts.map((d, i) => (
|
||||
{data.devTaskDrafts.map((d, i) => {
|
||||
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
|
||||
return (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
@@ -304,6 +321,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
{recommendation && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
title={recommendation.reason ?? ''}
|
||||
>
|
||||
推荐 {recommendation.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{d.description && (
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
|
||||
@@ -320,7 +345,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -336,7 +362,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的用例</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.testCaseDrafts.map((d, i) => (
|
||||
{data.testCaseDrafts.map((d, i) => {
|
||||
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
|
||||
return (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
@@ -359,6 +387,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
{recommendation && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
title={recommendation.reason ?? ''}
|
||||
>
|
||||
推荐 {recommendation.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
||||
{d.description}
|
||||
@@ -375,7 +411,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -383,10 +420,22 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||
已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
|
||||
</div>
|
||||
{visibleRecommendationCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRecommendedAssignees}
|
||||
onChange={(event) => setUseRecommendedAssignees(event.target.checked)}
|
||||
/>
|
||||
<span>采纳推荐负责人({visibleRecommendationCount})</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,13 @@
|
||||
- `estimateHours` 留给负责人/执行人确认后填写
|
||||
- AI 不输出预计开始/截止时间,排期由负责人后续维护
|
||||
|
||||
4. **历史版本不强制存储**
|
||||
5. **AI 可推荐负责人,但不自动分配**
|
||||
- AI 只能从当前版本成员 `members[].name` 中输出可选的 `recommendedAssigneeName`
|
||||
- 推荐只作为采纳弹窗里的辅助信息,默认不写入 `assigneeId`
|
||||
- 用户勾选“采纳推荐负责人”后,前端再次校验推荐姓名属于当前版本成员,才写入 DevTask/TestCase
|
||||
- 没有明确匹配成员时不输出推荐字段
|
||||
|
||||
6. **历史版本不强制存储**
|
||||
- 系统不要求历史原型 URL 作为基准
|
||||
- 拆偏问题靠"对账报告"暴露给用户,由人工补救
|
||||
- 这条决定见 decisions.md #17
|
||||
@@ -58,16 +64,30 @@
|
||||
|
||||
**输出**:
|
||||
- 对账报告(结构化文本,含"完美对应/单边/含糊"三段)
|
||||
- DevTask 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
|
||||
- TestCase 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
|
||||
- DevTask 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `recommendedAssigneeName` / `recommendedAssigneeReason`)
|
||||
- TestCase 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`,可选 `recommendedAssigneeName` / `recommendedAssigneeReason`)
|
||||
- `target = dev_tasks` 时 `testCaseDrafts` 必须为空数组;`target = test_cases` 时 `devTaskDrafts` 必须为空数组
|
||||
|
||||
**原型与需求匹配规则**:
|
||||
- 优先按需求编号匹配:原型批注或文本出现 `requirement.id` / `requirement.code` 时,必须优先判定为该需求命中。
|
||||
- 编号未出现时,再按需求标题和需求概述(`title` / `description`)做语义匹配。
|
||||
- 有 QY 编号时,`prototype_note` 引用只能使用真实存在的 QY 编号。
|
||||
- 没有 QY 编号但原型文本已命中需求编号或需求概述时,不应丢弃;可只引用 `requirement`,并在 `matched.noteIds` 返回空数组。
|
||||
- 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才进入 `noteOnly` 或 `ambiguous`。
|
||||
|
||||
**测试用例拆解粒度**:
|
||||
- TestCase 要按功能点、UI 交互、表单校验、接口、数据一致性、权限、异常、边界、状态流转、兼容性和回归点拆细。
|
||||
- 不允许用一条“验证 XX 完整流程”覆盖多个交互、多个接口或多个规则。
|
||||
- 一条 QY 若同时涉及 UI、接口、数据、异常和状态变化,通常应拆出 3-8 条 TestCase。
|
||||
- 测试用例 `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`。
|
||||
|
||||
**写入**:
|
||||
- 用户确认后,调用 `useDevTaskStore.createTask` 和 `useTestCaseStore.createTestCase`
|
||||
- 打开采纳弹窗前,前端先过滤当前版本已采纳过的重复 DevTask/TestCase 草案
|
||||
- 写入字段中 `aiDraft: true`、`aiDraftAt: ISO时间戳`
|
||||
- 写入 `aiEstimateHours`,不写入执行人预估 `estimateHours`
|
||||
- DevTask 草案不写预计开始/截止时间,负责人后续排期时再填写
|
||||
- 只有用户在采纳弹窗勾选“采纳推荐负责人”时,才把已校验的 `recommendedAssigneeName` 写入 `assigneeId`
|
||||
|
||||
**权限**:
|
||||
- 读:Version, Requirement, VersionPlan, Member
|
||||
@@ -82,7 +102,7 @@
|
||||
|
||||
**MVP 阶段限制**(V3.1):
|
||||
- 只生成 DevTask 和 TestCase 草案
|
||||
- 不自动分配 assignee(assignee 留给用户从草案编辑时手填)
|
||||
- 不自动分配 assignee;AI 只提供可选推荐,用户确认采纳后才写入
|
||||
- 不做"上一版基准 diff"
|
||||
|
||||
### Agent 2:Risk Watch Agent(风险预警)— 待规划
|
||||
@@ -208,8 +228,8 @@ interface DecomposeOutput {
|
||||
noteOnly: string[]; // 原型注释 ID 列表
|
||||
ambiguous: Array<{ noteId: string; reason: string }>;
|
||||
};
|
||||
devTaskDrafts: Array<{ title: string; description?: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
|
||||
testCaseDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
|
||||
devTaskDrafts: Array<{ title: string; description?: string; categoryCode: string; priority: Priority; aiEstimateHours: number; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>;
|
||||
testCaseDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: Reference[] }>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -367,3 +367,40 @@
|
||||
- 加班记录不受工作日历过滤,但不按自然小时连续打卡计算;使用项目管理口径:每个自然日默认 9:00-12:00、13:00-18:00 计 8h,中间完整日期按 8h,首尾日期按填写的开始/结束时间裁剪,结束晚于 18:00 的当天额外计入超出时长。
|
||||
|
||||
**理由**:正常任务耗时回答“工作时间里实际投入了多少”,加班记录回答“额外投入覆盖了多少项目工作量”。项目管理不做打卡,跨天记录不能把夜间空档算成工时;但节假日和周末仍允许记录,不受中国工作日历过滤。
|
||||
|
||||
## 31. AI 原型拆解优先按需求编号匹配,再按需求概述兜底
|
||||
|
||||
**问题**:原型文件里有些批注并不总是稳定写成 QY 编号,或者 QY 片段和需求之间没有显式绑定。仅按 QY 批注匹配会漏掉“原型里实际已有注释”的需求。
|
||||
|
||||
**决策**:
|
||||
- 原型上下文提取不只围绕 QY 编号,也围绕当前版本关联需求的 `id`、`code`、`title`、`description` 命中片段。
|
||||
- Agent 对账优先按需求编号匹配;原型文本出现 `requirement.id` 或 `requirement.code` 时必须优先归到该需求。
|
||||
- 没有需求编号时,再按需求标题和需求概述做语义匹配。
|
||||
- 没有 QY 编号但命中了需求编号或需求概述时,允许只引用 `requirement`,`matched.noteIds` 返回空数组。
|
||||
- 只有编号和概述都匹配不到的原型批注,才进入 `noteOnly` 或 `ambiguous`。
|
||||
|
||||
**理由**:需求编号是最稳定的对账锚点,需求概述是编号缺失时的业务语义兜底。把匹配证据先送进上下文,再用 prompt 明确优先级,比只依赖模型从截断文本里自由联想更稳定。
|
||||
|
||||
## 32. 测试用例类型细分,AI 拆解按测试点而不是大流程输出
|
||||
|
||||
**问题**:测试用例原先只有功能/API/异常/兼容性四类,AI 容易把多个交互、接口、数据状态和边界场景合并成一条“大用例”,导致测试范围不够细。
|
||||
|
||||
**决策**:
|
||||
- 在测试分组增加细分类:UI 交互、表单校验、数据一致性、权限、边界值、状态流转、回归测试。
|
||||
- AI tool schema、shared 类型和前端任务类型字典同步允许这些 `categoryCode`。
|
||||
- Prompt 明确要求 TestCase 按功能点、交互、接口、数据、权限、异常、边界、状态流转和回归点拆细。
|
||||
- 一条 QY 如果同时涉及 UI、接口、数据、异常和状态变化,通常应拆出 3-8 条 TestCase,不允许用单条“完整流程验证”兜住。
|
||||
|
||||
**理由**:测试用例的分类粒度会反过来影响 AI 输出粒度。更细的稳定语义码能让模型把测试范围拆开,也让后续统计、筛选和负责人评估更准确。
|
||||
|
||||
## 33. AI 可推荐负责人,但必须由用户确认后才写入
|
||||
|
||||
**问题**:版本成员已经维护完成后,AI 拆解任务时完全不看成员会浪费上下文;但如果 AI 直接分配负责人,容易把模型建议误认为团队承诺,也可能编造成员姓名造成脏数据。
|
||||
|
||||
**决策**:
|
||||
- AI 草案允许输出 `recommendedAssigneeName` 和 `recommendedAssigneeReason`,但姓名只能来自当前版本成员 `members[].name`。
|
||||
- 前端用规则 helper 再校验推荐姓名是否属于当前版本成员,非成员姓名直接丢弃。
|
||||
- 采纳弹窗默认不写入负责人;用户勾选“采纳推荐负责人”后,才把已校验推荐写入 DevTask/TestCase 的 `assigneeId`。
|
||||
- 没有明确角色匹配或成员匹配时,AI 不输出推荐字段,任务保持未分配,供成员后续领取或手动分配。
|
||||
|
||||
**理由**:负责人推荐能减少项目经理初次分配成本,但分配本身是团队执行承诺,必须由人确认。把推荐和写入分开,可以复用版本成员上下文,又避免模型幻觉姓名或越权自动派单。
|
||||
|
||||
@@ -10,9 +10,16 @@ export type AgentTaskCategoryCode =
|
||||
| 'database_schema'
|
||||
| 'api_integration'
|
||||
| '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'
|
||||
| 'data_processing'
|
||||
| 'implementation_support'
|
||||
| 'documentation';
|
||||
@@ -31,6 +38,8 @@ export interface AgentDevTaskDraft {
|
||||
categoryCode: AgentTaskCategoryCode;
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||
aiEstimateHours: number;
|
||||
recommendedAssigneeName?: string;
|
||||
recommendedAssigneeReason?: string;
|
||||
references: AgentReference[];
|
||||
}
|
||||
|
||||
@@ -40,6 +49,8 @@ export interface AgentTestCaseDraft {
|
||||
categoryCode: AgentTaskCategoryCode;
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||
aiEstimateHours: number;
|
||||
recommendedAssigneeName?: string;
|
||||
recommendedAssigneeReason?: string;
|
||||
references: AgentReference[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user