From 756621d36642496190df627c6910b3781384c930 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Wed, 1 Jul 2026 13:33:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E6=8B=86=E8=A7=A3=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E6=B1=82ID=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/modules/ai/ai.service.spec.ts | 66 +++++++++++++++++-- apps/server/src/modules/ai/ai.service.ts | 3 +- .../src/modules/ai/prompts/decompose.ts | 27 ++++++-- .../src/modules/ai/prototype-context.spec.ts | 19 ++++++ .../dev-task/DevTaskCreateModal.tsx | 5 +- apps/web/components/dev-task/DevTaskTab.tsx | 31 +++++++-- apps/web/components/test-case/TestCaseTab.tsx | 28 ++++++-- .../components/version/AiDecomposeButton.tsx | 2 +- .../version/DecomposeReportModal.tsx | 39 ++++++++--- apps/web/lib/ai-decompose-adoption.test.ts | 42 ++++++++++++ apps/web/lib/ai-decompose-adoption.ts | 46 +++++++++++++ apps/web/lib/ai-decompose-dedupe.test.ts | 2 +- apps/web/lib/ai-decompose-session.test.ts | 2 +- apps/web/lib/ai-decompose-target.test.ts | 2 +- apps/web/lib/dev-task.ts | 2 + apps/web/lib/test-case.ts | 3 + apps/web/lib/version-progress.test.ts | 26 ++++++++ apps/web/lib/version-progress.ts | 7 +- apps/web/lib/workspace-engine.test.ts | 37 ++++++++++- apps/web/lib/workspace-engine.ts | 2 +- apps/web/lib/xiaobao-risk-evidence.test.ts | 18 +++++ apps/web/lib/xiaobao-risk-evidence.ts | 2 +- packages/shared/src/agent.ts | 5 +- 23 files changed, 371 insertions(+), 45 deletions(-) create mode 100644 apps/web/lib/ai-decompose-adoption.test.ts create mode 100644 apps/web/lib/ai-decompose-adoption.ts diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 725afcd..b391bb6 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -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: [], }, diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 535ca87..0c381ed 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -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。 ## 版本成员 diff --git a/apps/server/src/modules/ai/prompts/decompose.ts b/apps/server/src/modules/ai/prompts/decompose.ts index 11590d7..62b30ed 100644 --- a/apps/server/src/modules/ai/prompts/decompose.ts +++ b/apps/server/src/modules/ai/prompts/decompose.ts @@ -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: { diff --git a/apps/server/src/modules/ai/prototype-context.spec.ts b/apps/server/src/modules/ai/prototype-context.spec.ts index 7e7d89c..a5d7fcc 100644 --- a/apps/server/src/modules/ai/prototype-context.spec.ts +++ b/apps/server/src/modules/ai/prototype-context.spec.ts @@ -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('预入职人员支持'); + }); }); diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 122892f..553922d 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -44,8 +44,8 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, [requirements, requirementIds], ); const versionTasks = useMemo( - () => tasks.filter((t) => requirementIds.includes(t.requirementId)), - [tasks, requirementIds], + () => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)), + [tasks, requirementIds, versionId], ); const [title, setTitle] = useState(''); @@ -108,6 +108,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, })), ]; createTask({ + versionId, requirementId, title: title.trim(), description: description.trim() || undefined, diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index 89818d0..e3a068f 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -28,6 +28,16 @@ interface Props { readOnly?: boolean; } +const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__'; + +function getDevTaskGroupKey(task: DevTask): string { + return task.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${task.requirementName || '未命名需求'}`; +} + +function getNoRequirementGroupName(groupKey: string): string { + return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求'; +} + export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) { const { tasks, fetchTasks, deleteTask } = useDevTaskStore(); const { categories, fetchCategories } = useTaskCategoryStore(); @@ -42,8 +52,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); const versionTasks = useMemo( - () => tasks.filter((t) => reqIdSet.has(t.requirementId)), - [tasks, reqIdSet], + () => tasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))), + [tasks, reqIdSet, versionId], ); const [filterAssignee, setFilterAssignee] = useState(''); @@ -107,9 +117,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl const groupedByReq = useMemo(() => { const map = new Map(); for (const t of paged) { - const list = map.get(t.requirementId) || []; + const key = getDevTaskGroupKey(t); + const list = map.get(key) || []; list.push(t); - map.set(t.requirementId, list); + map.set(key, list); } return map; }, [paged]); @@ -197,7 +208,9 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl ) : ( Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => { - const req = requirementMap.get(reqId); + const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX); + const req = isNoRequirementGroup ? undefined : requirementMap.get(reqId); + const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : ''; const reqProgress = calcGroupProgress(reqTasks); return (
@@ -209,8 +222,12 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl )}
- {req?.code} - {req?.title} + + {isNoRequirementGroup ? '无需求ID' : req?.code} + + + {isNoRequirementGroup ? noRequirementName : req?.title} + {reqProgress}%
diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index bf5d2f5..b327716 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -28,6 +28,16 @@ interface Props { readOnly?: boolean; } +const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__'; + +function getTestCaseGroupKey(testCase: { requirementId?: string; requirementName?: string }): string { + return testCase.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${testCase.requirementName || '未命名需求'}`; +} + +function getNoRequirementGroupName(groupKey: string): string { + return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求'; +} + export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) { const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); @@ -71,8 +81,8 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro ); const versionDevTasks = useMemo( - () => devTasks.filter((t) => reqIdSet.has(t.requirementId)), - [devTasks, reqIdSet], + () => devTasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))), + [devTasks, reqIdSet, versionId], ); const stats = calcTestProgress(activeRoundCases, versionBugs); @@ -150,7 +160,7 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro const groupedByReq = useMemo(() => { const map = new Map(); for (const c of paged) { - const key = c.requirementId || '__none__'; + const key = getTestCaseGroupKey(c); const list = map.get(key) || []; list.push(c); map.set(key, list); @@ -246,7 +256,9 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro ) : ( Array.from(groupedByReq.entries()).map(([reqId, cases]) => { - const req = reqId === '__none__' ? null : requirementMap.get(reqId); + const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX); + const req = isNoRequirementGroup ? null : requirementMap.get(reqId); + const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : ''; const reqPassed = cases.filter((c) => c.status === 'passed').length; const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null; return ( @@ -259,8 +271,12 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro )}
- {req?.code || '通用'} - {req?.title || '未关联需求'} + + {isNoRequirementGroup ? '无需求ID' : req?.code || '通用'} + + + {isNoRequirementGroup ? noRequirementName : req?.title || '未关联需求'} + {deliveryStatus && ( req.id)); const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, { - existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), + existingDevTasks: devTasks.filter((task) => task.versionId === version.id || (!task.versionId && requirementIdSet.has(task.requirementId))), existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), categories, requirements: linkedReqs, diff --git a/apps/web/components/version/DecomposeReportModal.tsx b/apps/web/components/version/DecomposeReportModal.tsx index f9a7427..80d3c84 100644 --- a/apps/web/components/version/DecomposeReportModal.tsx +++ b/apps/web/components/version/DecomposeReportModal.tsx @@ -10,6 +10,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category'; import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy'; +import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption'; import { formatReportRequirementLabel } from '@/lib/ai-decompose-report'; import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation'; import type { @@ -52,6 +53,7 @@ export function DecomposeReportModal({ const showTestDrafts = target !== 'dev_tasks'; const filteredDuplicateCount = (dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0); + const prototypeOnlyGroups = data.report.prototypeOnly ?? []; const [selectedDevIdx, setSelectedDevIdx] = useState>( new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []), @@ -108,14 +110,15 @@ export function DecomposeReportModal({ if (!selectedDevIdx.has(i)) continue; const draft = data.devTaskDrafts[i]; const refs = normalizeRefs(draft.references); - const reqRef = refs.find((r) => r.type === 'requirement'); - const requirementId = reqRef?.id ?? requirements[0]?.id ?? ''; + const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements); const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []); createTask({ - requirementId, + versionId: version.id, + requirementId: requirementFields.requirementId, + requirementName: requirementFields.requirementName, title: draft.title, description: draft.description, categoryId, @@ -148,14 +151,15 @@ export function DecomposeReportModal({ if (!selectedTcIdx.has(i)) continue; const draft = data.testCaseDrafts[i]; const refs = normalizeRefs(draft.references); - const reqRef = refs.find((r) => r.type === 'requirement'); + const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements); const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []); createTestCase({ versionId: version.id, - requirementId: reqRef?.id, + requirementId: requirementFields.requirementId || undefined, + requirementName: requirementFields.requirementName, title: draft.title, description: draft.description, categoryId, @@ -269,17 +273,22 @@ export function DecomposeReportModal({
)} - {data.report.noteOnly.length > 0 && ( + {prototypeOnlyGroups.length > 0 && (
- 仅原型未见需求 + 无需求ID分组

- 以下原型批注在需求清单中未提及,已跳过拆解(可能是漏录需求): + 以下原型批注明确可拆,但未匹配到关联需求;采纳后只进入任务/用例,不进入需求池或关联需求列表:

-
    - {data.report.noteOnly.map((id, i) =>
  • {id}
  • )} +
      + {prototypeOnlyGroups.map((item, i) => ( +
    • + 无需求ID · {item.requirementName} + ({item.noteIds.join(', ') || '无批注编号'} → 拆出 {item.taskCount} 个任务) +
    • + ))}
)} @@ -336,6 +345,11 @@ export function DecomposeReportModal({ AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h + {d.requirementName && ( + + 无需求ID · {d.requirementName} + + )} {recommendation && ( AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h + {d.requirementName && ( + + 无需求ID · {d.requirementName} + + )} {recommendation && ( { + const result = resolveDecomposeDraftRequirementFields( + { + requirementName: '预入职人员支持', + references: [{ type: 'prototype_note', id: 'QY0020', label: 'QY0020' }], + }, + requirements, + ); + + assert.deepEqual(result, { + requirementId: '', + requirementName: '预入职人员支持', + }); +}); + +test('maps formal requirement references by code during adoption', () => { + const result = resolveDecomposeDraftRequirementFields( + { + requirementName: '不应覆盖正式需求', + references: [ + { type: 'requirement', id: 'REQ002', label: 'REQ002 排行榜' }, + { type: 'prototype_note', id: 'QY0030', label: 'QY0030' }, + ], + }, + requirements, + ); + + assert.deepEqual(result, { + requirementId: 'req-2', + requirementName: undefined, + }); +}); diff --git a/apps/web/lib/ai-decompose-adoption.ts b/apps/web/lib/ai-decompose-adoption.ts new file mode 100644 index 0000000..7eda259 --- /dev/null +++ b/apps/web/lib/ai-decompose-adoption.ts @@ -0,0 +1,46 @@ +import type { AgentReference } from '@ftb/shared'; + +export interface DecomposeAdoptionRequirement { + id: string; + code: string; + title: string; +} + +export interface DecomposeDraftRequirementInput { + requirementName?: string; + references: AgentReference[]; +} + +export interface DecomposeDraftRequirementFields { + requirementId: string; + requirementName?: string; +} + +export function resolveDecomposeDraftRequirementFields( + draft: DecomposeDraftRequirementInput, + requirements: DecomposeAdoptionRequirement[], +): DecomposeDraftRequirementFields { + const reqRef = draft.references.find((ref) => ref.type === 'requirement'); + if (reqRef) { + const requirement = requirements.find((item) => item.id === reqRef.id || item.code === reqRef.id); + return { + requirementId: requirement?.id ?? reqRef.id, + requirementName: undefined, + }; + } + + return { + requirementId: '', + requirementName: normalizePrototypeOnlyRequirementName(draft.requirementName, draft.references), + }; +} + +function normalizePrototypeOnlyRequirementName( + requirementName: string | undefined, + references: AgentReference[], +): string { + const trimmed = requirementName?.trim(); + if (trimmed) return trimmed; + const note = references.find((ref) => ref.type === 'prototype_note'); + return note?.label?.trim() || note?.id?.trim() || '未命名需求'; +} diff --git a/apps/web/lib/ai-decompose-dedupe.test.ts b/apps/web/lib/ai-decompose-dedupe.test.ts index 7cb0ce9..18dbb3f 100644 --- a/apps/web/lib/ai-decompose-dedupe.test.ts +++ b/apps/web/lib/ai-decompose-dedupe.test.ts @@ -30,7 +30,7 @@ const requirements = [{ id: 'req-1', code: 'REQ001' }]; function makeResult(patch: Partial): AgentDecomposeResult { return { - report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] }, + report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] }, devTaskDrafts: [], testCaseDrafts: [], ...patch, diff --git a/apps/web/lib/ai-decompose-session.test.ts b/apps/web/lib/ai-decompose-session.test.ts index 641fa3f..e81f187 100644 --- a/apps/web/lib/ai-decompose-session.test.ts +++ b/apps/web/lib/ai-decompose-session.test.ts @@ -10,7 +10,7 @@ import { const response: AgentDecomposeResponse = { ok: true, result: { - report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] }, + report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] }, devTaskDrafts: [], testCaseDrafts: [], }, diff --git a/apps/web/lib/ai-decompose-target.test.ts b/apps/web/lib/ai-decompose-target.test.ts index e710531..b3152b3 100644 --- a/apps/web/lib/ai-decompose-target.test.ts +++ b/apps/web/lib/ai-decompose-target.test.ts @@ -6,7 +6,7 @@ import type { AgentDecomposeResult } from '@ftb/shared'; function makeResult(): AgentDecomposeResult { return { - report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] }, + report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] }, devTaskDrafts: [ { title: '实现登录表单', diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts index d7d80ad..014ba0b 100644 --- a/apps/web/lib/dev-task.ts +++ b/apps/web/lib/dev-task.ts @@ -15,7 +15,9 @@ export interface Reference { export interface DevTask { id: string; taskNo: string; + versionId?: string; requirementId: string; + requirementName?: string; title: string; description?: string; categoryId: string; diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts index 0f09549..ee0e0b2 100644 --- a/apps/web/lib/test-case.ts +++ b/apps/web/lib/test-case.ts @@ -12,6 +12,7 @@ export interface TestCase { caseNo: string; versionId: string; requirementId?: string; + requirementName?: string; roundNo?: number; sourceCaseId?: string; title: string; @@ -88,6 +89,7 @@ export function normalizeTestCase(testCase: Partial, index = 0): TestC caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`, versionId: testCase.versionId || '', requirementId: testCase.requirementId, + requirementName: testCase.requirementName, roundNo: getTestCaseRoundNo(testCase), sourceCaseId: testCase.sourceCaseId, title: testCase.title || `测试用例 ${index + 1}`, @@ -165,6 +167,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy return { versionId: source.versionId, requirementId: source.requirementId, + requirementName: source.requirementName, roundNo, sourceCaseId: source.sourceCaseId ?? source.id, title: source.title, diff --git a/apps/web/lib/version-progress.test.ts b/apps/web/lib/version-progress.test.ts index 2ae4cdd..9e47d75 100644 --- a/apps/web/lib/version-progress.test.ts +++ b/apps/web/lib/version-progress.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { calcVersionProgress } from './version-progress'; +import type { DevTask } from './dev-task'; import type { Requirement } from './requirement'; import type { VersionPlan } from './version-plan'; @@ -64,3 +65,28 @@ test('version progress uses explicit requirement coverage before legacy complete assert.equal(progress, 0); }); + +test('version progress includes no-requirement-id dev tasks by version id', () => { + const noReqTask = { + id: 'dev-no-req', + taskNo: 'DEV-001', + versionId: 'version-1', + requirementId: '', + requirementName: '预入职人员支持', + title: '实现预入职人员选择', + categoryId: 'frontend', + assigneeId: '', + priority: 'P1', + expectedStartAt: '', + expectedEndAt: '', + status: 'submitted', + isBlocked: false, + createdBy: 'AI', + createdAt: '2026-06-29T09:00:00.000Z', + updatedAt: '2026-06-29T10:00:00.000Z', + } as unknown as DevTask; + + const progress = calcVersionProgress('version-1', [], [], [noReqTask], []); + + assert.equal(progress, 100); +}); diff --git a/apps/web/lib/version-progress.ts b/apps/web/lib/version-progress.ts index ac3a48b..6da908b 100644 --- a/apps/web/lib/version-progress.ts +++ b/apps/web/lib/version-progress.ts @@ -20,7 +20,7 @@ export function calcVersionProgress( const vPlans = plans.filter((p) => p.versionId === versionId); const vReqs = requirements.filter((r) => r.versionId === versionId); const vReqIds = new Set(vReqs.map((r) => r.id)); - const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId)); + const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds)); const vTestCases = testCases.filter((c) => c.versionId === versionId); const segments: number[] = []; @@ -78,6 +78,11 @@ export function calcVersionProgress( return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0; } +function isDevTaskInVersion(task: DevTask, versionId: string, requirementIds: Set): boolean { + if (task.versionId) return task.versionId === versionId; + return requirementIds.has(task.requirementId); +} + /** * 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map */ diff --git a/apps/web/lib/workspace-engine.test.ts b/apps/web/lib/workspace-engine.test.ts index 25ee0b5..0617a54 100644 --- a/apps/web/lib/workspace-engine.test.ts +++ b/apps/web/lib/workspace-engine.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { getWorkspacePendingCount } from './workspace-engine'; +import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine'; +import type { DevTask } from './dev-task'; test('getWorkspacePendingCount counts unfinished work items only', () => { const count = getWorkspacePendingCount([ @@ -11,3 +12,37 @@ test('getWorkspacePendingCount counts unfinished work items only', () => { assert.equal(count, 2); }); + +test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => { + const task = { + id: 'dev-no-req', + taskNo: 'DEV-001', + versionId: 'ver-1', + requirementId: '', + requirementName: '预入职人员支持', + title: '实现预入职人员选择', + categoryId: 'frontend', + assigneeId: '张三', + priority: 'P1', + expectedStartAt: '', + expectedEndAt: '', + status: 'todo', + isBlocked: false, + createdBy: 'AI', + createdAt: '2026-06-29T09:00:00.000Z', + updatedAt: '2026-06-29T09:00:00.000Z', + } as DevTask; + + const items = aggregateWorkItems( + '张三', + [], + [task], + [], + [], + new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]), + new Map(), + ); + + assert.equal(items[0].versionId, 'ver-1'); + assert.equal(items[0].versionName, 'V1.0'); +}); diff --git a/apps/web/lib/workspace-engine.ts b/apps/web/lib/workspace-engine.ts index 078faf9..49b128b 100644 --- a/apps/web/lib/workspace-engine.ts +++ b/apps/web/lib/workspace-engine.ts @@ -58,7 +58,7 @@ export function aggregateWorkItems( // DevTasks: assigneeId === userName devTasks.filter((t) => t.assigneeId === userName).forEach((t) => { - const versionId = requirementVersionMap.get(t.requirementId) ?? ''; + const versionId = t.versionId || requirementVersionMap.get(t.requirementId) || ''; const ver = versionMap.get(versionId); items.push({ id: t.id, diff --git a/apps/web/lib/xiaobao-risk-evidence.test.ts b/apps/web/lib/xiaobao-risk-evidence.test.ts index 563ae27..0272791 100644 --- a/apps/web/lib/xiaobao-risk-evidence.test.ts +++ b/apps/web/lib/xiaobao-risk-evidence.test.ts @@ -363,3 +363,21 @@ test('buildXiaobaoWorkItems keeps all version work without current-user filterin assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']); assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']); }); + +test('buildXiaobaoWorkItems uses direct version id for no-requirement dev tasks', () => { + const items = buildXiaobaoWorkItems({ + devTasks: [ + devTask({ + id: 'dev-no-req', + versionId: 'ver-1', + requirementId: '', + requirementName: '预入职人员支持', + }), + ], + versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }], + requirementVersionMap: new Map(), + }); + + assert.equal(items[0].versionId, 'ver-1'); + assert.equal(items[0].versionName, 'V1.0'); +}); diff --git a/apps/web/lib/xiaobao-risk-evidence.ts b/apps/web/lib/xiaobao-risk-evidence.ts index d0d47ad..c201bb2 100644 --- a/apps/web/lib/xiaobao-risk-evidence.ts +++ b/apps/web/lib/xiaobao-risk-evidence.ts @@ -92,7 +92,7 @@ export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkIt } for (const task of input.devTasks ?? []) { - const versionId = requirementVersionMap.get(task.requirementId) ?? ''; + const versionId = task.versionId || requirementVersionMap.get(task.requirementId) || ''; const version = versionMap.get(versionId); items.push({ id: task.id, diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 1602f3d..d2b32d7 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -38,6 +38,7 @@ export interface AgentDevTaskDraft { categoryCode: AgentTaskCategoryCode; priority: 'P0' | 'P1' | 'P2' | 'P3'; aiEstimateHours: number; + requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: AgentReference[]; @@ -49,6 +50,7 @@ export interface AgentTestCaseDraft { categoryCode: AgentTaskCategoryCode; priority: 'P0' | 'P1' | 'P2' | 'P3'; aiEstimateHours: number; + requirementName?: string; recommendedAssigneeName?: string; recommendedAssigneeReason?: string; references: AgentReference[]; @@ -57,7 +59,8 @@ export interface AgentTestCaseDraft { export interface AgentDecomposeReport { matched: Array<{ reqId: string; noteIds: string[]; taskCount: number }>; reqOnly: string[]; - noteOnly: string[]; + prototypeOnly: Array<{ requirementName: string; noteIds: string[]; taskCount: number }>; + noteOnly?: string[]; ambiguous: Array<{ noteId: string; reason: string }>; }