fix(web): prefer intake markers over the shorter URS title

「初始用户需求表」contains「用户需求表」, so title matching sent V0.1 wording to the V0.5 task and the stage gate rejected it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 06:12:15 +00:00
co-authored by Cursor
parent 247e549397
commit ddb1e542b6
2 changed files with 75 additions and 3 deletions
+27
View File
@@ -239,3 +239,30 @@ test('v3 bd_ task key wins over unprefixed intake alias', () => {
const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', [bdIntake]);
assert.equal(resolved?.taskKey, 'bd_intake');
});
test('word-order variants of the intake sheet stay on intake, not the shorter URS title', () => {
const bdIntake: DeliverableItem = {
...base,
docId: 'doc_proj6579_bd_intake',
projectId: 6579,
taskKey: 'bd_intake',
title: '用户初始需求收集表',
isTemplateOnly: true,
};
const bdUrs: DeliverableItem = {
...base,
docId: 'doc_proj6579_bd_user_requirements',
projectId: 6579,
taskKey: 'bd_user_requirements',
title: '用户需求表',
stage: 'V0.5',
isTemplateOnly: true,
};
const pool = [bdIntake, bdUrs];
for (const query of ['编写初始用户需求表', '生成需求表', '编写用户初始需求收集表']) {
assert.equal(resolveTaskKeyFromMessage(query), 'intake', query);
assert.equal(resolveTaskTemplateForMessage(query, pool)?.taskKey, 'bd_intake', query);
assert.equal(resolveTaskTemplateForMessage(query, bothPool)?.taskKey, 'intake', query);
}
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements');
});
+48 -3
View File
@@ -7,7 +7,10 @@ const WRITE_INTENT =
const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/;
const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [
{ taskKey: 'intake', patterns: [/用户初始需求收集表/, /初始需求表/, /需求摸底表/] },
{
taskKey: 'intake',
patterns: [/用户初始需求收集表/, /初始用户需求表/, /用户初始需求/, /初始需求表/, /需求摸底表/],
},
{ taskKey: 'user_requirements', patterns: [/用户需求表/, /\bURS\b/, /用户需求规格/] },
{ taskKey: 'research', patterns: [/调研报告/, /竞品调研/] },
{ taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] },
@@ -34,6 +37,29 @@ export function hasDocumentWriteIntent(text: string): boolean {
return WRITE_INTENT.test(normalized);
}
function isRequirementsTopic(text: string): boolean {
return (
text.includes('需求') ||
text.includes('摸底') ||
text.includes('用户') ||
text.includes('首轮')
);
}
/** V0.1 摸底的强标记。语序换了也要赢过更短的「用户需求表」子串。 */
export function hasIntakeSemanticMarker(text: string): boolean {
return (
text.includes('首轮') ||
text.includes('初始') ||
text.includes('需求摸底') ||
text.includes('摸底')
);
}
function isUserRequirementsKey(taskKey: string | undefined): boolean {
return taskKey === 'user_requirements' || Boolean(taskKey?.endsWith('_user_requirements'));
}
export function refersToCurrentControlledDocument(text: string): boolean {
return CURRENT_DOC_DEIXIS.test(text.trim());
}
@@ -64,7 +90,12 @@ function explicitNamedDeliverableTaskKey(
.filter((d) => d.taskKey && d.taskKey !== excludeTaskKey && d.title?.trim())
.sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0));
for (const d of titled) {
if (text.includes(d.title)) return d.taskKey;
if (!text.includes(d.title)) continue;
// 「用户需求表」是「初始用户需求表」的子串,不能盖过 V0.1 强标记。
if (isUserRequirementsKey(d.taskKey) && isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
continue;
}
return d.taskKey;
}
return namedAliasTaskKey(text, excludeTaskKey);
}
@@ -117,6 +148,9 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl
function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | undefined {
for (const alias of NAMED_DELIVERABLE_ALIASES) {
if (alias.taskKey === excludeTaskKey) continue;
if (isUserRequirementsKey(alias.taskKey) && isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
continue;
}
if (alias.patterns.some((p) => p.test(text))) return alias.taskKey;
}
return undefined;
@@ -146,11 +180,16 @@ export function resolveTaskKeyFromMessage(text: string): string | undefined {
// 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。
if (!hasDocumentWriteIntent(normalized)) return undefined;
// 「初始 / 首轮 / 摸底」优先于别名子串,避免「初始用户需求表」被「用户需求表」截走。
if (isRequirementsTopic(normalized) && hasIntakeSemanticMarker(normalized)) {
return 'intake';
}
// 明确点名的交付物优先于否定背景/旧文档里的「需求」等关键词。
const named = namedAliasTaskKey(normalized);
if (named) return named;
if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) {
if (isRequirementsTopic(normalized)) {
return resolveRequirementsTaskKey(normalized);
}
if (normalized.includes('调研') || normalized.includes('竞品')) return 'research';
@@ -214,6 +253,12 @@ export function resolveTaskTemplateForMessage(
}
}
if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
const taskKey = normalizeTaskKeyToProject('intake', deliverables);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
return { taskKey, template };
}
const named = explicitNamedDeliverableTaskKey(text, deliverables);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, deliverables);