Files
Agent-System/web/src/engine/templateMatch.ts
T
weichenandCursor ddb1e542b6 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>
2026-09-21 06:12:15 +00:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DeliverableItem } from '../types/workbench';
const READ_ONLY_INTENT =
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const WRITE_INTENT =
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/;
const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [
{
taskKey: 'intake',
patterns: [/用户初始需求收集表/, /初始用户需求表/, /用户初始需求/, /初始需求表/, /需求摸底表/],
},
{ taskKey: 'user_requirements', patterns: [/用户需求表/, /\bURS\b/, /用户需求规格/] },
{ taskKey: 'research', patterns: [/调研报告/, /竞品调研/] },
{ taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] },
{ taskKey: 'feasibility_dev', patterns: [/设计开发可行性/, /研发可行性评估/] },
{ taskKey: 'prelim_design', patterns: [/系统设计输入/, /初步设计说明书/, /系统设计规格/] },
{ taskKey: 'cdo_quote', patterns: [/CDO报价/, /报价核算表/] },
];
export type ResolvedDocumentTask = {
taskKey: string;
template?: DeliverableItem;
docId?: string;
pinnedToActiveDoc?: boolean;
};
function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
return deliverables.filter((d) => d.isTemplateOnly);
}
/** 只读动词优先;无明确写动作则不启动文档任务。 */
export function hasDocumentWriteIntent(text: string): boolean {
const normalized = text.trim();
if (READ_ONLY_INTENT.test(normalized)) return false;
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());
}
export function taskKeyFromControlledDocId(docId: string): string | undefined {
const m = docId.trim().match(/^doc_proj\d+_(.+)$/);
return m?.[1] || undefined;
}
/** 服务端已注册的受控文档:doc_proj* 且非模板槽。 */
export function isServerRegisteredControlledDoc(doc?: DeliverableItem | null): boolean {
if (!doc || doc.isTemplateOnly) return false;
if (!/^doc_proj\d+_/.test(doc.docId)) return false;
return Boolean(doc.taskKey || taskKeyFromControlledDocId(doc.docId));
}
/** 路由用 taskKey:docId 后缀与声明不一致时以 ID 为准(与服务端权威 ID 对齐)。 */
export function consistentTaskKeyForControlledDoc(doc: DeliverableItem): string | undefined {
return taskKeyFromControlledDocId(doc.docId) || doc.taskKey || undefined;
}
function explicitNamedDeliverableTaskKey(
text: string,
deliverables: DeliverableItem[],
excludeTaskKey?: string,
): string | undefined {
const titled = deliverables
.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)) continue;
// 「用户需求表」是「初始用户需求表」的子串,不能盖过 V0.1 强标记。
if (isUserRequirementsKey(d.taskKey) && isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
continue;
}
return d.taskKey;
}
return namedAliasTaskKey(text, excludeTaskKey);
}
/**
* 从文案判定 intake(V0.1)还是 user_requirements(V0.5),与交付物池无关。
* 含「首轮 / 初始 / 需求摸底 / 摸底」的明确 V0.1 文案必须 intake,即使同时含「用户需求」。
* intake 与 user_requirements 仍是两个真实任务键,不做全局别名。
*/
export function resolveRequirementsTaskKey(text: string): 'intake' | 'user_requirements' {
const wantsIntake =
text.includes('首轮') ||
text.includes('初始') ||
text.includes('需求摸底') ||
text.includes('摸底') ||
text.includes('初始需求') ||
text.includes('初始需求表');
if (wantsIntake) return 'intake';
const wantsUrs =
text.includes('用户需求表') ||
text.includes('URS') ||
text.includes('用户需求');
if (wantsUrs) return 'user_requirements';
// 含糊表述默认 V0.1 摸底表,避免误触 V0.5 用户需求表导致阶段门禁 403
return 'intake';
}
function findTemplateByTaskKey(
pool: DeliverableItem[],
taskKey: string,
): DeliverableItem | undefined {
return pool.find((d) => d.taskKey === taskKey);
}
/** 在 intake / user_requirements 等同语候选里按文案选择任务键,再匹配模板交付物。 */
function pickRequirementsTask(text: string, pool: DeliverableItem[]): DeliverableItem | undefined {
const taskKey = resolveRequirementsTaskKey(text);
const hit = findTemplateByTaskKey(pool, taskKey);
if (hit) return hit;
// 池里可能只有 URS 模板;仍返回虚拟条目以便上层使用正确 taskKey
const fallback = findTemplateByTaskKey(pool, taskKey === 'intake' ? 'user_requirements' : 'intake');
if (fallback) {
return { ...fallback, taskKey };
}
return undefined;
}
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;
}
/**
* 把语义任务键对齐到当前项目的实际键。
* 精确命中优先;否则仅当唯一后缀为 `_${semanticKey}` 时改写(intake → bd_intake)。
*/
export function normalizeTaskKeyToProject(semanticKey: string, deliverables: DeliverableItem[]): string {
const keys = [
...new Set(
deliverables
.map((item) => item.taskKey)
.filter((key): key is string => Boolean(key)),
),
];
if (keys.includes(semanticKey)) return semanticKey;
const suffix = `_${semanticKey}`;
const hits = keys.filter((key) => key.endsWith(suffix));
return hits.length === 1 ? hits[0] : semanticKey;
}
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
export function resolveTaskKeyFromMessage(text: string): string | undefined {
const normalized = text.trim();
// 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。
if (!hasDocumentWriteIntent(normalized)) return undefined;
// 「初始 / 首轮 / 摸底」优先于别名子串,避免「初始用户需求表」被「用户需求表」截走。
if (isRequirementsTopic(normalized) && hasIntakeSemanticMarker(normalized)) {
return 'intake';
}
// 明确点名的交付物优先于否定背景/旧文档里的「需求」等关键词。
const named = namedAliasTaskKey(normalized);
if (named) return named;
if (isRequirementsTopic(normalized)) {
return resolveRequirementsTaskKey(normalized);
}
if (normalized.includes('调研') || normalized.includes('竞品')) return 'research';
if (normalized.includes('可行性') || normalized.includes('注册') || normalized.includes('报告')) {
if (normalized.includes('研发') || normalized.includes('dev')) return 'feasibility_dev';
return 'feasibility_reg';
}
if (normalized.includes('设计') || normalized.includes('规格') || normalized.includes('架构')) return 'prelim_design';
if (normalized.includes('报价') || normalized.includes('工时') || normalized.includes('预算')) return 'cdo_quote';
return undefined;
}
/** 根据用户输入在模板交付物中解析应触发的文档任务。 */
export function matchTemplateDeliverable(
text: string,
deliverables: DeliverableItem[],
): DeliverableItem | undefined {
const pool = templatePool(deliverables);
const taskKey = resolveTaskKeyFromMessage(text);
if (!taskKey) return undefined;
const aligned = normalizeTaskKeyToProject(taskKey, deliverables);
const hit = findTemplateByTaskKey(pool, aligned) || findTemplateByTaskKey(pool, taskKey);
if (hit) return { ...hit, taskKey: aligned };
if (taskKey === 'intake' || taskKey === 'user_requirements') {
const picked = pickRequirementsTask(text, pool);
return picked ? { ...picked, taskKey: aligned } : undefined;
}
return undefined;
}
/**
* 合并文案解析与模板匹配。
* 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId,
* 不被正文领域关键词改路由;显式点名另一交付物仍可切换。
*/
export function resolveTaskTemplateForMessage(
text: string,
deliverables: DeliverableItem[],
activeDoc?: DeliverableItem | null,
): ResolvedDocumentTask | undefined {
if (!hasDocumentWriteIntent(text)) return undefined;
if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) {
const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!);
const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, deliverables);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
return { taskKey, template };
}
if (pinnedKey) {
const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text);
return {
taskKey: pinnedKey,
template,
docId: activeDoc!.docId,
pinnedToActiveDoc: true,
};
}
}
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);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
return { taskKey, template };
}
const rawKey = resolveTaskKeyFromMessage(text);
if (!rawKey) return undefined;
const taskKey = normalizeTaskKeyToProject(rawKey, deliverables);
const template = matchTemplateDeliverable(text, deliverables);
return { taskKey, template };
}
function matchTemplateDeliverableByKey(
taskKey: string,
deliverables: DeliverableItem[],
text: string,
): DeliverableItem | undefined {
const pool = templatePool(deliverables);
const hit = findTemplateByTaskKey(pool, taskKey);
if (hit) return hit;
if (taskKey === 'intake' || taskKey === 'user_requirements') {
return pickRequirementsTask(text, pool);
}
return undefined;
}