From 0b1040ca7e6c6229beae4450fc6eda1b259bd1e0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:09:36 +0000 Subject: [PATCH] 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 --- internal/docgov/project_docs.go | 1 + internal/server/chat_document_reader.go | 11 +++ internal/server/project_doc_handlers.go | 9 +- web/src/App.tsx | 2 + web/src/api/projectDocuments.ts | 31 ++++++- web/src/engine/templateMatch.test.ts | 43 ++++++++++ web/src/engine/templateMatch.ts | 104 +++++++++++++++++++++--- web/src/hooks/useSendMessage.ts | 23 +++++- web/src/hooks/useTaskRunAttempts.ts | 15 +++- web/src/lib/deliverableSync.ts | 2 + web/src/types/workbench.ts | 2 + 11 files changed, 226 insertions(+), 17 deletions(-) diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 8312edf0..c869e2ef 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -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"` diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 284618f4..ca32bdc6 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -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 diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index cd787383..efda8b9a 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -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 正文。 diff --git a/web/src/App.tsx b/web/src/App.tsx index 5ff30de5..8d2a2f2d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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, diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 0fd139e7..32f61d8a 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -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 { + 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 { diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index ef8ed59c..6aff9962 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index ae119704..d589e9f2 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -24,12 +24,83 @@ export type ResolvedDocumentTask = { template?: DeliverableItem; docId?: string; pinnedToActiveDoc?: boolean; + /** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */ + wrongWorkflow?: { + docTitle: string; + ownerWorkflow: string; + activeWorkflow: string; + }; }; +const WORKFLOW_LABELS: Record = { + 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): 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 }; } diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index e27ad999..fa339f64 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -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>; setDocCache: Dispatch>>; @@ -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 演示默认值,五要素完整建模留待后续 diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index cf36f74f..667d0cf3 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -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; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; @@ -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, diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index 00065ccd..9a95d5ae 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -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); diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index ba171e69..998e77a9 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -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;