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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user