From ddb1e542b6987802882d70a36e1d5177d852e388 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:12:15 +0000 Subject: [PATCH] fix(web): prefer intake markers over the shorter URS title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「初始用户需求表」contains「用户需求表」, so title matching sent V0.1 wording to the V0.5 task and the stage gate rejected it. Co-authored-by: Cursor --- web/src/engine/templateMatch.test.ts | 27 +++++++++++++++ web/src/engine/templateMatch.ts | 51 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 024a9edf..ef8ed59c 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 0a2d0aba..ae119704 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -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);