Files
Agent-System/web/src/engine/templateMatch.ts
T
weichenandCursor 568408cced feat(web): degrade stage-blocked task runs to chat confirmation
Keep weak write verbs off the task shortcut path, and once-only fall back
to chat when TASK_SCOPE_UNRESOLVED or DOC_TASK_NOT_IN_ACTIVE_STAGE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 08:27:49 +00:00

372 lines
14 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;
/** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */
wrongWorkflow?: {
docTitle: string;
ownerWorkflow: string;
activeWorkflow: string;
};
};
const WORKFLOW_LABELS: Record<string, string> = {
main: '商务对接流程',
product_manager: '智能产品经理流程',
test_manager: '智能测试工程师流程',
rdev: '研发设计控制流程',
software_bootstrap: '软件项目自举流程',
software_delivery: '软件交付迭代流程',
};
/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */
const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [
{ workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] },
{ workflow: 'product_manager', patterns: [/系统需求规格书/] },
{ workflow: 'test_manager', patterns: [/系统验证方案及计划/, /系统验证方案/] },
];
export function workflowDisplayLabel(key: string): string {
return WORKFLOW_LABELS[key] || key;
}
function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
return deliverables.filter((d) => d.isTemplateOnly);
}
/** 激活流匹配池:无激活键或文档未标注 workflowKey 时保持全量(存量兼容)。 */
function scopedDeliverables(
deliverables: DeliverableItem[],
activeWorkflowKey?: string,
): DeliverableItem[] {
if (!activeWorkflowKey) return deliverables;
return deliverables.filter((d) => !d.workflowKey || d.workflowKey === activeWorkflowKey);
}
function detectWrongWorkflowMention(
text: string,
deliverables: DeliverableItem[],
activeWorkflowKey: string,
): ResolvedDocumentTask['wrongWorkflow'] | undefined {
const outside = deliverables
.filter((d) => d.workflowKey && d.workflowKey !== activeWorkflowKey && d.title?.trim())
.sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0));
for (const d of outside) {
if (text.includes(d.title)) {
return {
docTitle: d.title,
ownerWorkflow: d.workflowKey!,
activeWorkflow: activeWorkflowKey,
};
}
}
for (const alias of CROSS_WORKFLOW_ALIASES) {
if (alias.workflow === activeWorkflowKey) continue;
for (const p of alias.patterns) {
if (p.test(text)) {
return {
docTitle: text.match(p)?.[0] || alias.patterns[0].source,
ownerWorkflow: alias.workflow,
activeWorkflow: activeWorkflowKey,
};
}
}
}
return undefined;
}
export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wrongWorkflow']>): string {
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
}
/** 只读动词优先;无明确写动作则不启动文档任务。 */
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,
* 不被正文领域关键词改路由;显式点名另一交付物仍可切换。
* activeWorkflowKey 非空时:匹配池收窄到激活流;显式点名他流文档返回 wrongWorkflow。
*/
export function resolveTaskTemplateForMessage(
text: string,
deliverables: DeliverableItem[],
activeDoc?: DeliverableItem | null,
activeWorkflowKey?: string,
): ResolvedDocumentTask | undefined {
if (!hasDocumentWriteIntent(text)) return undefined;
if (activeWorkflowKey) {
const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey);
if (wrong) {
return { taskKey: '', wrongWorkflow: wrong };
}
}
const scoped = scopedDeliverables(deliverables, activeWorkflowKey);
if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) {
const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!);
const named = explicitNamedDeliverableTaskKey(text, scoped, pinnedKey);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, scoped);
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
return { taskKey, template };
}
if (pinnedKey) {
const template = matchTemplateDeliverableByKey(pinnedKey, scoped, text);
return {
taskKey: pinnedKey,
template,
docId: activeDoc!.docId,
pinnedToActiveDoc: true,
};
}
}
if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
const taskKey = normalizeTaskKeyToProject('intake', scoped);
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
return { taskKey, template };
}
const named = explicitNamedDeliverableTaskKey(text, scoped);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, scoped);
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
return { taskKey, template };
}
const rawKey = resolveTaskKeyFromMessage(text);
if (!rawKey) return undefined;
const taskKey = normalizeTaskKeyToProject(rawKey, scoped);
const template = matchTemplateDeliverable(text, scoped);
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;
}