fix(ai): 拆解原型无需求ID任务
This commit is contained in:
@@ -278,7 +278,7 @@ describe('AiService', () => {
|
||||
.mockResolvedValueOnce({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
@@ -321,7 +321,7 @@ describe('AiService', () => {
|
||||
.mockResolvedValueOnce({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
@@ -371,7 +371,7 @@ describe('AiService', () => {
|
||||
const callTool = jest.fn().mockResolvedValue({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
@@ -443,6 +443,27 @@ describe('AiService', () => {
|
||||
expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason');
|
||||
});
|
||||
|
||||
it('tells the model to decompose clear prototype-only notes into no-requirement groups', () => {
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('prototypeOnly');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('无需求ID');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('requirementName');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('只有无法形成稳定任务/用例的含糊批注才进入 ambiguous');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('仅 QY 命中、需求未提的批注,不拆任务');
|
||||
expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('noteOnly');
|
||||
});
|
||||
|
||||
it('allows requirementName on drafts and reports prototypeOnly groups in the tool schema', () => {
|
||||
const reportSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.report as any);
|
||||
const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items;
|
||||
const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items;
|
||||
|
||||
expect(reportSchema.properties.prototypeOnly).toBeDefined();
|
||||
expect(reportSchema.required).toContain('prototypeOnly');
|
||||
expect(reportSchema.required).not.toContain('noteOnly');
|
||||
expect(devSchema.properties.requirementName).toEqual({ type: 'string' });
|
||||
expect(testCaseSchema.properties.requirementName).toEqual({ type: 'string' });
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -463,7 +484,7 @@ describe('AiService', () => {
|
||||
const callTool = jest.fn().mockResolvedValue({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
@@ -494,11 +515,46 @@ describe('AiService', () => {
|
||||
expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
|
||||
});
|
||||
|
||||
it('repeats the no-requirement group rule in the decomposition user prompt', async () => {
|
||||
const callTool = jest.fn().mockResolvedValue({
|
||||
toolName: 'submit_decompose',
|
||||
toolInput: {
|
||||
report: { matched: [], reqOnly: [], prototypeOnly: [], 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('QY0020:预入职人员支持,可搜索、选择预入职人员并成功创建案例。');
|
||||
|
||||
await service.decompose({
|
||||
prototypeUrl: 'https://example.com/prototype',
|
||||
requirements: [{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' }],
|
||||
members: [{ name: '张三', role: 'frontend' }],
|
||||
versionId: 'version-1',
|
||||
planId: 'plan-1',
|
||||
});
|
||||
|
||||
const userPrompt = callTool.mock.calls[0][0].userPrompt;
|
||||
expect(userPrompt).toContain('无需求ID');
|
||||
expect(userPrompt).toContain('prototypeOnly');
|
||||
expect(userPrompt).not.toContain('才放入 noteOnly 或 ambiguous');
|
||||
});
|
||||
|
||||
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: [] },
|
||||
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
|
||||
@@ -356,7 +356,8 @@ ${reqList || '(无)'}
|
||||
1. 优先按需求编号匹配:原型批注或文本中出现 requirement.id 或 requirement.code 时,必须优先判定为该需求命中。
|
||||
2. 如果没有出现需求编号,再按需求概述语义匹配:用需求 title 和 description 与原型批注/文本表达做对应。
|
||||
3. 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号;没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃该需求,可只使用 requirement 引用并在 matched.noteIds 返回空数组。
|
||||
4. 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous。
|
||||
4. 既匹配不到需求编号,也匹配不到需求概述语义,但能形成明确功能名称和任务/用例范围的原型批注,必须进入 prototypeOnly 无需求ID分组并继续拆解。
|
||||
5. prototypeOnly 草案不带 requirement 引用,但必须带 requirementName 和至少 1 条真实 prototype_note 引用;只有无法形成稳定任务/用例的批注才进入 ambiguous。
|
||||
|
||||
## 版本成员
|
||||
|
||||
|
||||
@@ -24,14 +24,17 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
||||
- 第二优先级:没有需求编号时,再用需求 title / description(需求概述)与原型批注或文本做语义匹配
|
||||
- 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号
|
||||
- 没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃;可只引用 requirement,并在 matched.noteIds 返回空数组
|
||||
- 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous
|
||||
- 既匹配不到需求编号,也匹配不到需求概述语义,但能形成明确功能名称和任务/用例范围的原型批注,必须进入 prototypeOnly 无需求ID分组并继续拆解
|
||||
- 只有无法形成稳定任务/用例的含糊批注才进入 ambiguous
|
||||
|
||||
3. 任务来源限定
|
||||
- 只为以下情况拆任务:
|
||||
a) 同时被需求和原型 QY 命中
|
||||
b) 需求编号或需求概述在原型文本中命中,但没有 QY 编号
|
||||
c) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
|
||||
- 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出
|
||||
d) 原型 QY 批注明确可拆,但没有匹配到任何关联需求
|
||||
- d) 必须生成无需求ID草案:report.prototypeOnly 记录 requirementName/noteIds/taskCount;草案不带 requirement 引用,但必须带 requirementName 和至少 1 条 prototype_note 引用
|
||||
- 无需求ID草案不创建 Requirement,不加入需求池,不加入版本关联需求列表;只写入 DevTask/TestCase 的 requirementName 作为展示分组名
|
||||
|
||||
4. 颗粒度(细颗粒)
|
||||
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
|
||||
@@ -79,13 +82,14 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
||||
【对账报告要求】
|
||||
- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务)
|
||||
- reqOnly: 需求里有但原型未见 → 列出需求 ID
|
||||
- noteOnly: 原型里有但需求未提 → 列出 QY 编号
|
||||
- prototypeOnly: 原型里有明确功能但没有匹配到关联需求 → 列出 requirementName、QY 编号和拆出任务数
|
||||
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
|
||||
|
||||
【特殊情况】
|
||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,但能匹配到需求编号或需求概述 → 仍按命中的需求拆解,matched.noteIds 返回空数组
|
||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,也匹配不到任何需求编号或需求概述 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组
|
||||
- 若原型完全无法解析 → 同上处理
|
||||
- 若草案没有 requirement 引用,必须有 requirementName;requirementName 使用该批注的稳定功能名称,例如"预入职人员支持"
|
||||
|
||||
【输出通道强制要求】
|
||||
- 第一块响应内容必须是 submit_decompose 的 tool_use
|
||||
@@ -116,7 +120,18 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
},
|
||||
},
|
||||
reqOnly: { type: 'array', items: { type: 'string' } },
|
||||
noteOnly: { type: 'array', items: { type: 'string' } },
|
||||
prototypeOnly: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requirementName: { type: 'string' },
|
||||
noteIds: { type: 'array', items: { type: 'string' } },
|
||||
taskCount: { type: 'integer' },
|
||||
},
|
||||
required: ['requirementName', 'noteIds', 'taskCount'],
|
||||
},
|
||||
},
|
||||
ambiguous: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@@ -129,7 +144,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['matched', 'reqOnly', 'noteOnly', 'ambiguous'],
|
||||
required: ['matched', 'reqOnly', 'prototypeOnly', 'ambiguous'],
|
||||
},
|
||||
devTaskDrafts: {
|
||||
type: 'array',
|
||||
@@ -154,6 +169,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
},
|
||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||
aiEstimateHours: { type: 'number' },
|
||||
requirementName: { type: 'string' },
|
||||
recommendedAssigneeName: { type: 'string' },
|
||||
recommendedAssigneeReason: { type: 'string' },
|
||||
references: {
|
||||
@@ -198,6 +214,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
||||
},
|
||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||
aiEstimateHours: { type: 'number' },
|
||||
requirementName: { type: 'string' },
|
||||
recommendedAssigneeName: { type: 'string' },
|
||||
recommendedAssigneeReason: { type: 'string' },
|
||||
references: {
|
||||
|
||||
@@ -48,4 +48,23 @@ describe('buildPrototypeContext', () => {
|
||||
expect(context.text).toContain('客户批量导入');
|
||||
expect(context.text).not.toContain('更多噪声'.repeat(20));
|
||||
});
|
||||
|
||||
it('keeps clear user-information QY notes with actionable details', () => {
|
||||
const content = [
|
||||
'页面说明'.repeat(500),
|
||||
'QY0005用户信息化优化:离职状态员工用户名正常显示 优先级 / 范围:P1 · Web/App 详细说明 后端统一所有页面的人员姓名取值,不因离职、实习、停用等状态返回空或乱码。姓名显示长度放宽至20个汉字,前端超长以“...”截断,并提供点击或长按查看全名能力。案例列表、详情、主人翁选择器、排行榜等涉及姓名的位置同步修改。',
|
||||
'QY0019 人员选择优化:同名员工身份区分与主人翁筛选 所有人员选择器中,姓名后新增名片icon,点击可展示人员组织详情页。主人翁筛选框支持按名字模糊搜索,输入关键字实时过滤下拉列表。无匹配结果时显示“无结果”。',
|
||||
'QY0020 预入职人员支持 人员数据源新增“预入职”状态人员,由HR系统同步。主人翁选择器可搜索并选择预入职人员,其他交互与在职员工一致。预入职人员显示“预入职”标签或特殊标识。可搜索、选择预入职人员并成功创建案例。',
|
||||
'更多页面说明'.repeat(500),
|
||||
].join(' ');
|
||||
|
||||
const context = buildPrototypeContext(content, 1800);
|
||||
|
||||
expect(context.text).toContain('QY0005');
|
||||
expect(context.text).toContain('离职状态员工用户名正常显示');
|
||||
expect(context.text).toContain('QY0019');
|
||||
expect(context.text).toContain('同名员工身份区分');
|
||||
expect(context.text).toContain('QY0020');
|
||||
expect(context.text).toContain('预入职人员支持');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user