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

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

View File

@@ -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_usecontent=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('需求概述');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveRecommendedAssignee } from './ai-assignee-recommendation';
import type { VersionMember } from './derive';
const members: VersionMember[] = [
{ name: 'Alice', role: 'frontend' },
{ name: 'Bob', role: 'testing' },
];
test('resolves an AI recommended assignee only when the name is a current version member', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'Alice',
recommendedAssigneeReason: 'Frontend implementation matches this member role.',
},
members,
);
assert.deepEqual(recommendation, {
name: 'Alice',
role: 'frontend',
reason: 'Frontend implementation matches this member role.',
});
});
test('ignores hallucinated recommended assignee names', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'Carol',
recommendedAssigneeReason: 'Model invented a person.',
},
members,
);
assert.equal(recommendation, undefined);
});
test('does not resolve case-mismatched member names', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'alice',
recommendedAssigneeReason: 'Lowercase is not an exact member name.',
},
members,
);
assert.equal(recommendation, undefined);
});

View File

@@ -0,0 +1,29 @@
import type { VersionMember } from './derive';
export interface RecommendedAssigneeDraft {
recommendedAssigneeName?: string;
recommendedAssigneeReason?: string;
}
export interface ResolvedRecommendedAssignee {
name: string;
role: VersionMember['role'];
reason?: string;
}
export function resolveRecommendedAssignee(
draft: object,
members: VersionMember[] | undefined,
): ResolvedRecommendedAssignee | undefined {
const recommendation = draft as RecommendedAssigneeDraft;
if (!recommendation.recommendedAssigneeName) return undefined;
const member = (members ?? []).find((candidate) => candidate.name === recommendation.recommendedAssigneeName);
if (!member) return undefined;
const reason = recommendation.recommendedAssigneeReason?.trim();
return {
name: member.name,
role: member.role,
...(reason ? { reason } : {}),
};
}

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterDecomposeResultByTarget } from './ai-decompose-target';
import type { AgentDecomposeResult } from '@ftb/shared';
function makeResult(): AgentDecomposeResult {
return {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
devTaskDrafts: [
{
title: '实现登录表单',
categoryCode: 'frontend_development',
priority: 'P2',
aiEstimateHours: 0.5,
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
},
],
testCaseDrafts: [
{
title: '验证登录成功',
description: '输入正确账号密码后进入首页',
categoryCode: 'test_functional',
priority: 'P2',
aiEstimateHours: 0.2,
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
},
],
};
}
test('keeps only dev task drafts for dev task decomposition target', () => {
const original = makeResult();
const filtered = filterDecomposeResultByTarget(original, 'dev_tasks');
assert.equal(filtered.devTaskDrafts.length, 1);
assert.equal(filtered.testCaseDrafts.length, 0);
assert.equal(original.testCaseDrafts.length, 1);
});
test('keeps only test case drafts for test case decomposition target', () => {
const filtered = filterDecomposeResultByTarget(makeResult(), 'test_cases');
assert.equal(filtered.devTaskDrafts.length, 0);
assert.equal(filtered.testCaseDrafts.length, 1);
});
test('keeps both draft groups for all decomposition target', () => {
const filtered = filterDecomposeResultByTarget(makeResult(), 'all');
assert.equal(filtered.devTaskDrafts.length, 1);
assert.equal(filtered.testCaseDrafts.length, 1);
});

View File

@@ -0,0 +1,14 @@
import type { AgentDecomposeResult, AgentDecomposeTarget } from '@ftb/shared';
export function filterDecomposeResultByTarget(
result: AgentDecomposeResult,
target: AgentDecomposeTarget = 'all',
): AgentDecomposeResult {
if (target === 'dev_tasks') {
return { ...result, testCaseDrafts: [] };
}
if (target === 'test_cases') {
return { ...result, devTaskDrafts: [] };
}
return result;
}

View File

@@ -23,4 +23,7 @@ test('defaults and clamps test case AI estimates below half an hour when appropr
assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
assert.equal(clampTestCaseAiEstimateHours('test_form_validation', 0.05), 0.1);
assert.equal(clampTestCaseAiEstimateHours('test_data_consistency', 2), 0.5);
assert.equal(getDefaultTestCaseAiEstimateHours('test_regression'), 0.25);
});

View File

@@ -16,16 +16,30 @@ const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
test_ui_interaction: { min: 0.1, max: 0.3, fallback: 0.2 },
test_form_validation: { min: 0.1, max: 0.25, fallback: 0.2 },
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
test_data_consistency: { min: 0.2, max: 0.5, fallback: 0.3 },
test_permission: { min: 0.2, max: 0.5, fallback: 0.3 },
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
test_boundary: { min: 0.1, max: 0.3, fallback: 0.2 },
test_state_flow: { min: 0.2, max: 0.5, fallback: 0.3 },
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
test_regression: { min: 0.15, max: 0.4, fallback: 0.25 },
};
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
test_ui_interaction: { min: 0.25, max: 2, fallback: 0.5 },
test_form_validation: { min: 0.25, max: 2, fallback: 0.5 },
test_api: { min: 0.25, max: 2, fallback: 0.5 },
test_data_consistency: { min: 0.25, max: 2, fallback: 0.5 },
test_permission: { min: 0.25, max: 2, fallback: 0.5 },
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
test_boundary: { min: 0.25, max: 2, fallback: 0.5 },
test_state_flow: { min: 0.25, max: 2, fallback: 0.5 },
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
test_regression: { min: 0.25, max: 2, fallback: 0.5 },
};
function roundToStep(hours: number, step: number): number {

View File

@@ -12,6 +12,17 @@ import {
test('preset categories include stable codes and testing group', () => {
assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
for (const code of [
'test_ui_interaction',
'test_form_validation',
'test_data_consistency',
'test_permission',
'test_boundary',
'test_state_flow',
'test_regression',
]) {
assert.ok(PRESET_CATEGORIES.some((c) => c.code === code && c.group === 'testing'), code);
}
assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
});

View File

@@ -27,9 +27,16 @@ export const PRESET_CATEGORIES: TaskCategory[] = [
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', color: '#06b6d4', sortOrder: 21, isSystem: true },
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', color: '#84cc16', sortOrder: 22, isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 23, isSystem: true },
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', color: '#10b981', sortOrder: 24, isSystem: true },
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', color: '#8b5cf6', sortOrder: 25, isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 26, isSystem: true },
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', color: '#f59e0b', sortOrder: 27, isSystem: true },
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', color: '#6366f1', sortOrder: 28, isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 29, isSystem: true },
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', color: '#64748b', sortOrder: 30, isSystem: true },
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },

View File

@@ -1,3 +1,5 @@
import type { AgentDecomposeTarget } from '@ftb/shared';
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
@@ -73,6 +75,7 @@ export interface VersionPlan {
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
aiDecomposeAt?: string;
aiDecomposeBy?: string;
aiDecomposeTarget?: AgentDecomposeTarget;
aiDecomposeError?: string;
}

View File

@@ -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 草案
- 不自动分配 assigneeassignee 留给用户从草案编辑时手填)
- 不自动分配 assigneeAI 只提供可选推荐,用户确认采纳后才写入
- 不做"上一版基准 diff"
### Agent 2Risk 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[] }>;
}
```

View File

@@ -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 不输出推荐字段,任务保持未分配,供成员后续领取或手动分配。
**理由**:负责人推荐能减少项目经理初次分配成本,但分配本身是团队执行承诺,必须由人确认。把推荐和写入分开,可以复用版本成员上下文,又避免模型幻觉姓名或越权自动派单。

View File

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