feat(web): scope template matching to the active workflow

- Document list returns workflow_key per item and active_workflow_key
- DeliverableItem keeps workflowKey through server sync
- resolveTaskTemplateForMessage filters the match pool and blocks
  cross-workflow mentions with switch guidance

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 08:09:36 +00:00
co-authored by Cursor
parent 0912efdbd9
commit 0b1040ca7e
11 changed files with 226 additions and 17 deletions
+1
View File
@@ -20,6 +20,7 @@ type WorkflowDocumentSummary struct {
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
Title string `json:"title"`
WorkflowKey string `json:"workflow_key,omitempty"`
LifecycleState DocumentState `json:"lifecycle_state"`
IsTemplateOnly bool `json:"is_template_only"`
ContentSHA256 string `json:"content_sha256"`
+11
View File
@@ -108,6 +108,17 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。
func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]bool) string {
if activeKey != "" && len(scopeTaskKeys) > 0 && projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) {
return activeKey
}
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" {
return owner
}
return activeKey
}
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
type errDocWrongWorkflow struct {
Title string
+8 -1
View File
@@ -95,7 +95,14 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
return
}
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
writeJSONResponse(w, http.StatusOK, docs)
activeKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(r.Context(), projectID)
for i := range docs {
docs[i].WorkflowKey = workflowKeyForDocument(docs[i].TaskKey, activeKey, scopeTaskKeys)
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"documents": docs,
"active_workflow_key": activeKey,
})
}
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
+2
View File
@@ -1185,6 +1185,7 @@ export default function App() {
deliverables,
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey: currentWfState.activeKey || undefined,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
@@ -1383,6 +1384,7 @@ export default function App() {
deliverables,
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey: currentWfState.activeKey || undefined,
sessions,
setDeliverables,
setDocCache,
+30 -1
View File
@@ -7,6 +7,7 @@ export type WorkflowDocumentSummary = {
project_id: number;
task_key: string;
title: string;
workflow_key?: string;
lifecycle_state: string;
is_template_only: boolean;
content_sha256?: string;
@@ -26,6 +27,11 @@ export type WorkflowDocumentSummary = {
duty_policy?: string;
};
export type ProjectWorkflowDocumentsResponse = {
documents: WorkflowDocumentSummary[];
active_workflow_key?: string;
};
export type ControlledDocConsistency =
| 'aligned'
| 'missing'
@@ -55,7 +61,30 @@ export async function listProjectWorkflowDocuments(
throw new Error(`list project workflow documents failed: ${resp.status}`);
}
const data = await resp.json();
return Array.isArray(data) ? data : [];
// 兼容旧数组响应与新 { documents, active_workflow_key } 包装。
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.documents)) return data.documents;
return [];
}
export async function listProjectWorkflowDocumentsDetailed(
projectId: number,
): Promise<ProjectWorkflowDocumentsResponse> {
const resp = await fetch(
`/api/v1/projects/${projectId}/documents?category=workflow`,
{ headers: getBearerAuthHeaders() },
);
if (!resp.ok) {
throw new Error(`list project workflow documents failed: ${resp.status}`);
}
const data = await resp.json();
if (Array.isArray(data)) {
return { documents: data };
}
return {
documents: Array.isArray(data?.documents) ? data.documents : [],
active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined,
};
}
export class ControlledContentRequestError extends Error {
+43
View File
@@ -266,3 +266,46 @@ test('word-order variants of the intake sheet stay on intake, not the shorter UR
}
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements');
});
test('resolveTaskTemplateForMessage blocks cross-workflow document mentions', () => {
const mainResearch: DeliverableItem = {
...base,
taskKey: 'bd_research',
title: '调研报告',
workflowKey: 'main',
};
const rdevProposal: DeliverableItem = {
...base,
taskKey: 'rd_proposal',
title: '项目建议书',
workflowKey: 'rdev',
isTemplateOnly: true,
};
const pool = [mainResearch, rdevProposal];
const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main');
assert.ok(blocked?.wrongWorkflow);
assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev');
assert.equal(blocked?.taskKey, '');
const allowed = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'rdev');
assert.equal(allowed?.wrongWorkflow, undefined);
assert.equal(allowed?.taskKey, 'rd_proposal');
const legacy = resolveTaskTemplateForMessage('编写项目建议书', pool, null, undefined);
assert.equal(legacy?.wrongWorkflow, undefined);
assert.equal(legacy?.taskKey, 'rd_proposal');
});
test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main', () => {
const research: DeliverableItem = {
...base,
taskKey: 'research',
title: '调研报告',
workflowKey: 'main',
isTemplateOnly: true,
};
const resolved = resolveTaskTemplateForMessage('生成调研报告', [research], null, 'main');
assert.equal(resolved?.wrongWorkflow, undefined);
assert.equal(resolved?.taskKey, 'research');
});
+93 -11
View File
@@ -24,12 +24,83 @@ export type ResolvedDocumentTask = {
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();
@@ -226,24 +297,35 @@ export function matchTemplateDeliverable(
* 合并文案解析与模板匹配。
* 指示「当前受控文档」且 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, deliverables, pinnedKey);
const named = explicitNamedDeliverableTaskKey(text, scoped, pinnedKey);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, deliverables);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
const taskKey = normalizeTaskKeyToProject(named, scoped);
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
return { taskKey, template };
}
if (pinnedKey) {
const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text);
const template = matchTemplateDeliverableByKey(pinnedKey, scoped, text);
return {
taskKey: pinnedKey,
template,
@@ -254,22 +336,22 @@ export function resolveTaskTemplateForMessage(
}
if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
const taskKey = normalizeTaskKeyToProject('intake', deliverables);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
const taskKey = normalizeTaskKeyToProject('intake', scoped);
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
return { taskKey, template };
}
const named = explicitNamedDeliverableTaskKey(text, deliverables);
const named = explicitNamedDeliverableTaskKey(text, scoped);
if (named) {
const taskKey = normalizeTaskKeyToProject(named, deliverables);
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
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, deliverables);
const template = matchTemplateDeliverable(text, deliverables);
const taskKey = normalizeTaskKeyToProject(rawKey, scoped);
const template = matchTemplateDeliverable(text, scoped);
return { taskKey, template };
}
+21 -2
View File
@@ -11,7 +11,7 @@ import {
isAffirmativeResponse,
} from '../engine/cardActionEngine';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { createClientTurnId } from '../lib/createClientTurnId';
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
import { holdsDocDuty } from '../lib/docGovActionVisibility';
@@ -95,6 +95,8 @@ export interface SendMessageInput {
deliverables: DeliverableItem[];
visibleDeliverables: DeliverableItem[];
currentProjectDeliverables: DeliverableItem[];
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
activeWorkflowKey?: string;
sessions: ProjectSession[];
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
@@ -131,6 +133,7 @@ export function useSendMessage(input: SendMessageInput) {
deliverables,
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey,
sessions,
setDeliverables,
setDocCache,
@@ -309,11 +312,27 @@ export function useSendMessage(input: SendMessageInput) {
text,
currentProjectDeliverables,
sendActiveDoc,
activeWorkflowKey,
);
if (resolvedTask?.wrongWorkflow) {
setMessages((prev) => [
...prev,
{
id: 'ast-wrong-wf-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: wrongWorkflowGuidance(resolvedTask.wrongWorkflow!),
timestamp: new Date().toTimeString().substring(0, 5),
status: 'completed',
},
]);
return { accepted: true };
}
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
if (resolvedTask) {
if (resolvedTask?.taskKey) {
const asstId = 'ast-' + Date.now();
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
+13 -2
View File
@@ -11,7 +11,7 @@ import { listProjectWorkflowDocuments } from '../api/projectDocuments';
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
import { buildTaskRequest, streamTask } from '../api/taskStream';
import { applyPhaseEvent } from '../engine/phaseEventMapper';
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
import {
controlledHydrateCandidateIds,
@@ -68,6 +68,7 @@ export interface TaskRunAttemptsInput {
deliverables: DeliverableItem[];
visibleDeliverables: DeliverableItem[];
currentProjectDeliverables: DeliverableItem[];
activeWorkflowKey?: string;
deliverablesRef: MutableRefObject<DeliverableItem[]>;
activeDocIdRef: MutableRefObject<string>;
editorHydrationGateRef: MutableRefObject<Gate>;
@@ -99,6 +100,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
deliverables,
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
@@ -803,8 +805,17 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
userText,
currentProjectDeliverables,
retryActive,
activeWorkflowKey,
);
const retryTask = reResolved
if (reResolved?.wrongWorkflow) {
chatWindow.updateLiveMessage(messageId, {
content: wrongWorkflowGuidance(reResolved.wrongWorkflow),
livePhaseLabel: undefined,
status: 'completed',
});
return;
}
const retryTask = reResolved?.taskKey
? {
...target.taskRequest,
taskKey: reResolved.taskKey,
+2
View File
@@ -115,6 +115,7 @@ export function mergeWorkflowDocumentsFromServer(
docId: s.doc_id,
projectId,
taskKey: s.task_key,
workflowKey: s.workflow_key,
title: s.title,
stage: 'V0.5',
category: 'workflow',
@@ -136,6 +137,7 @@ export function mergeWorkflowDocumentsFromServer(
base.docId = s.doc_id;
base.taskKey = s.task_key;
base.workflowKey = s.workflow_key || base.workflowKey;
base.title = s.title || base.title;
base.isTemplateOnly = s.is_template_only;
base.lifecycleState = mapLifecycleState(s.lifecycle_state);
+2
View File
@@ -307,6 +307,8 @@ export interface DeliverableItem {
projectId: number;
taskKey: string;
title: string;
/** 文档所属工作流键(main / product_manager / rdev …);缺省表示存量未标注。 */
workflowKey?: string;
stage: StageKey;
category: DeliverableCategory;
creatorId: number;