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>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
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;
|
||
}
|