From 0912efdbd9346e61300b35961acccd1ec16bef0a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:05:44 +0000 Subject: [PATCH] feat(coordinator+server): narrow document match pool to active workflow - ActiveWorkflowScope resolves live instance task keys for the project - chat document reader matches inside the active pool; cross-flow hits return ErrWrongWorkflow with switch guidance - enrichSessionWorkflowStage injects ActiveWorkflowKey into UserCtx - TRIGGER_TASK (coordinator + task-run API) blocks out-of-scope task keys Co-authored-by: Cursor --- internal/coordinator/context.go | 19 +++ internal/coordinator/document_summary.go | 3 + internal/coordinator/engine.go | 39 +++++ internal/intent/doc_workflow_scope.go | 15 ++ internal/projectinstance/workflow_scope.go | 71 ++++++++ .../projectinstance/workflow_scope_test.go | 45 ++++++ internal/server/chat_document_reader.go | 153 +++++++++++++++++- internal/server/chat_document_reader_test.go | 48 +++++- internal/server/handlers.go | 8 + internal/server/stage_access_helpers.go | 11 ++ 10 files changed, 403 insertions(+), 9 deletions(-) create mode 100644 internal/projectinstance/workflow_scope.go create mode 100644 internal/projectinstance/workflow_scope_test.go diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index af70dd21..18267a34 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -3,6 +3,7 @@ package coordinator import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -75,6 +76,24 @@ type ReadableDocument struct { // 协调层不得直接枚举文档,以免绕过阶段可见性规则。 type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDocument, error) +// ErrWrongWorkflow 表示目标文档存在但不属于当前激活工作流。 +// 服务端文档读取器按激活流收窄匹配池后,用该错误携带「请先切换工作流」引导话术, +// 协调层识别后原样回复,不执行任何读/写动作。 +type ErrWrongWorkflow struct { + Message string +} + +func (e *ErrWrongWorkflow) Error() string { return e.Message } + +// WrongWorkflowGuidance 若错误链上是跨工作流拦截,返回引导话术;否则空串。 +func WrongWorkflowGuidance(err error) string { + var target *ErrWrongWorkflow + if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" { + return target.Message + } + return "" +} + // UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费) type UserProjectContext struct { mu sync.Mutex diff --git a/internal/coordinator/document_summary.go b/internal/coordinator/document_summary.go index 1ab8d0e9..04bcd231 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -28,6 +28,9 @@ func (e *CoordinatorEngine) handleDocumentSummary( doc, err := userCtx.DocumentSummaryReader(ctx, target) if err != nil || doc == nil { + if guidance := WrongWorkflowGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil } if doc.IsTemplateOnly { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index b88aa9e0..ab466d78 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -928,11 +928,50 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent } func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + // 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。 + if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + active := "" + if userCtx != nil { + active = userCtx.Project.ActiveWorkflowKey + } + guidance := cand.ClarificationQuestion + if guidance == "" { + workflows := []string{} + if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" { + workflows = strings.Split(v, ",") + } + guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active) + } + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + taskKey := cand.FilledSlots[intent.SlotTargetTask] if taskKey == "" { taskKey = "intake" } + // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 + if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { + if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey { + guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index 7e432453..a82af48b 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -306,6 +306,21 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn } } +// WorkflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。 +func WorkflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string { + return workflowScopeGuidanceQuestion(doc, workflows, activeWorkflow) +} + +// WorkflowDisplayName 返回工作流键的中文展示名。 +func WorkflowDisplayName(key string) string { + return workflowDisplayName(key) +} + +// TaskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。 +func TaskKeyWorkflowOwner(taskKey string) string { + return taskKeyWorkflowOwner(taskKey) +} + // workflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。 func workflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string { names := make([]string, 0, len(workflows)) diff --git a/internal/projectinstance/workflow_scope.go b/internal/projectinstance/workflow_scope.go new file mode 100644 index 00000000..883bdcf0 --- /dev/null +++ b/internal/projectinstance/workflow_scope.go @@ -0,0 +1,71 @@ +package projectinstance + +import ( + "context" + + "chat-agent-system/internal/workflowdef" +) + +// ActiveWorkflowScope 返回项目当前激活工作流的键与文档任务键集合。 +// +// 语义(供意图填槽与文档正文读取收窄作用域): +// - 从未激活(无 live 实例)→ workflowKey 为空、taskKeys 为 nil → 调用方不得过滤(legacy / planning) +// - live 系实例 → workflowKey 取自 projects.active_workflow_key(回落实例快照定义键), +// taskKeys 收集全部启用文档任务,并补齐旧键/新键双向别名 +// - completed / terminated 不属于激活:active_workflow_key 已在终止时清空,视为未激活 +// - 存储错误原样上抛(fail-closed) +func (s *Service) ActiveWorkflowScope(ctx context.Context, projectID int64) (workflowKey string, taskKeys map[string]bool, err error) { + if s == nil || s.store == nil || projectID <= 0 { + return "", nil, nil + } + _, activeKey, _, _, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return "", nil, err + } + if activeKey == "" { + // 未激活:不过滤,保持现状行为。 + return "", nil, nil + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil { + return "", nil, err + } + if live == nil { + // 有激活键但无 live 快照:仅返回键供话术,任务集合交由调用方按空处理(不过滤)。 + return activeKey, nil, nil + } + if live.ConfigSnapshot.WorkflowDefinitionKey != "" { + workflowKey = live.ConfigSnapshot.WorkflowDefinitionKey + } else { + workflowKey = activeKey + } + taskKeys = make(map[string]bool) + for _, st := range live.ConfigSnapshot.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled || dt.TaskKey == "" { + continue + } + taskKeys[dt.TaskKey] = true + // 补齐旧键/新键双向别名,冻结实例与发布清单互指。 + if canonical := workflowdef.CanonicalTaskKey(dt.TaskKey); canonical != dt.TaskKey { + taskKeys[canonical] = true + } + if legacy := workflowdef.ImplementationTaskKey(dt.TaskKey); legacy != dt.TaskKey { + taskKeys[legacy] = true + } + } + } + return workflowKey, taskKeys, nil +} + +// TaskKeyInScope 判断任务键是否落在激活工作流任务集合内。 +// taskKeys 为空(未激活 / 无快照)时一律放行,保持现状。 +func TaskKeyInScope(taskKeys map[string]bool, taskKey string) bool { + if len(taskKeys) == 0 { + return true + } + return mapHasTaskKey(taskKeys, taskKey) +} diff --git a/internal/projectinstance/workflow_scope_test.go b/internal/projectinstance/workflow_scope_test.go new file mode 100644 index 00000000..cb7578a2 --- /dev/null +++ b/internal/projectinstance/workflow_scope_test.go @@ -0,0 +1,45 @@ +package projectinstance_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/projectinstance" +) + +func TestActiveWorkflowScope_NoLiveInstanceReturnsEmpty(t *testing.T) { + store := projectinstance.NewMemoryStore() + if err := projectinstance.SeedProjectMeta(store, 99, projectinstance.LifecyclePlanning, "", 1, 0); err != nil { + t.Fatalf("seed: %v", err) + } + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + key, tasks, err := svc.ActiveWorkflowScope(context.Background(), 99) + if err != nil { + t.Fatalf("scope: %v", err) + } + if key != "" || tasks != nil { + t.Fatalf("unactivated project must return empty scope, got key=%q tasks=%v", key, tasks) + } +} + +func TestTaskKeyInScope_EmptyAllowsAll(t *testing.T) { + if !projectinstance.TaskKeyInScope(nil, "anything") { + t.Fatal("nil scope must allow") + } + if !projectinstance.TaskKeyInScope(map[string]bool{}, "anything") { + t.Fatal("empty scope must allow") + } +} + +func TestTaskKeyInScope_CanonicalAlias(t *testing.T) { + scope := map[string]bool{"bd_research": true, "research": true} + if !projectinstance.TaskKeyInScope(scope, "research") { + t.Fatal("legacy alias research should be in scope") + } + if !projectinstance.TaskKeyInScope(scope, "bd_research") { + t.Fatal("canonical bd_research should be in scope") + } + if projectinstance.TaskKeyInScope(scope, "rd_proposal") { + t.Fatal("rd_proposal must be out of scope") + } +} diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 059d9cb6..284618f4 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -10,6 +10,8 @@ import ( "chat-agent-system/internal/chat" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/projectinstance" ) // bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。 @@ -40,7 +42,13 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth return nil, err } docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs) - doc, err := selectReadableWorkflowDocument(docs, target) + // 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。 + // 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。 + workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) + if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" { + workflowKey = sess.UserCtx.Project.ActiveWorkflowKey + } + doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys) if err != nil { return nil, err } @@ -60,21 +68,152 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth } } -func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, target string) (*docgov.WorkflowDocumentSummary, error) { +// activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。 +func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) { + if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 { + return "", nil + } + workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID) + if err != nil { + return "", nil + } + return workflowKey, taskKeys +} + +// taskKeyOutsideActiveWorkflow 判断任务键是否落在激活工作流之外。 +// 未激活 / 任务键未知归属时放行(存量兼容);跨流时返回引导话术。 +func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int64, taskKey string) (bool, string) { + taskKey = strings.TrimSpace(taskKey) + if taskKey == "" || projectID <= 0 { + return false, "" + } + workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) + if workflowKey == "" { + return false, "" + } + if len(scopeTaskKeys) > 0 { + if projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) { + return false, "" + } + owner := intent.TaskKeyWorkflowOwner(taskKey) + if owner == "" || owner == workflowKey { + return false, "" + } + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) + } + owner := intent.TaskKeyWorkflowOwner(taskKey) + if owner == "" || owner == workflowKey { + return false, "" + } + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) +} + +// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 +type errDocWrongWorkflow struct { + Title string + TaskKey string + OwnerWorkflow string + ActiveWorkflow string +} + +func (e *errDocWrongWorkflow) Error() string { + if e == nil { + return "document belongs to another workflow" + } + owners := []string{} + if e.OwnerWorkflow != "" { + owners = []string{e.OwnerWorkflow} + } + doc := e.Title + if doc == "" { + doc = e.TaskKey + } + return intent.WorkflowScopeGuidanceQuestion(doc, owners, e.ActiveWorkflow) +} + +func (e *errDocWrongWorkflow) AsCoordinatorErr() error { + return &coordinator.ErrWrongWorkflow{Message: e.Error()} +} + +// selectReadableWorkflowDocument 在可读文档池中解析目标。 +// scopeTaskKeys 非空时先在激活流池内匹配;未命中但精确/归一化匹配到被剔除的他流文档时返回 ErrWrongWorkflow。 +// scopeTaskKeys 为空(未激活 / planning)时保持现状:在全量池内匹配。 +func selectReadableWorkflowDocument( + docs []docgov.WorkflowDocumentSummary, + target string, + activeWorkflow string, + scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { if len(docs) == 0 { return nil, fmt.Errorf("no readable workflow documents") } targetKey := normalizeDocumentLookupKey(target) if targetKey == "" { - if len(docs) == 1 { - return &docs[0], nil + inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) + pool := inScope + if len(scopeTaskKeys) == 0 { + pool = docs + } + if len(pool) == 1 { + return &pool[0], nil } return nil, fmt.Errorf("document target required") } + inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) + pool := inScope + if len(scopeTaskKeys) == 0 { + pool = docs + } + if hit := matchDocumentInPool(pool, target, targetKey); hit != nil { + return hit, nil + } + // 激活流池未命中:检查是否精确/归一化命中被剔除的他流文档。 + if len(scopeTaskKeys) > 0 { + outOfScope := filterDocsOutsideTaskKeys(docs, scopeTaskKeys) + if hit := matchDocumentInPool(outOfScope, target, targetKey); hit != nil { + owner := intent.TaskKeyWorkflowOwner(hit.TaskKey) + return nil, (&errDocWrongWorkflow{ + Title: hit.Title, + TaskKey: hit.TaskKey, + OwnerWorkflow: owner, + ActiveWorkflow: activeWorkflow, + }).AsCoordinatorErr() + } + } + return nil, fmt.Errorf("document not found in readable scope") +} + +func filterDocsByTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { + if len(taskKeys) == 0 { + return docs + } + out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) + for _, d := range docs { + if projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { + out = append(out, d) + } + } + return out +} + +func filterDocsOutsideTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { + if len(taskKeys) == 0 { + return nil + } + out := make([]docgov.WorkflowDocumentSummary, 0) + for _, d := range docs { + if !projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { + out = append(out, d) + } + } + return out +} + +func matchDocumentInPool(docs []docgov.WorkflowDocumentSummary, target, targetKey string) *docgov.WorkflowDocumentSummary { for i := range docs { if target == docs[i].DocID || target == docs[i].TaskKey || target == docs[i].Title { - return &docs[i], nil + return &docs[i] } } for i := range docs { @@ -85,11 +224,11 @@ func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, targe } for _, key := range keys { if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) { - return &docs[i], nil + return &docs[i] } } } - return nil, fmt.Errorf("document not found in readable scope") + return nil } func normalizeDocumentLookupKey(value string) string { diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 7f5007f1..9db568f4 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -1,8 +1,11 @@ package server import ( + "errors" + "strings" "testing" + "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" ) @@ -11,7 +14,7 @@ func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testin {DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"}, } - doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表") + doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil) if err != nil { t.Fatalf("select: %v", err) } @@ -25,7 +28,48 @@ func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing. {DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_b", TaskKey: "research", Title: "调研报告"}, } - if _, err := selectReadableWorkflowDocument(docs, ""); err == nil { + if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil { t.Fatal("expected target-required error") } } + +func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) { + docs := []docgov.WorkflowDocumentSummary{ + {DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"}, + {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, + } + scope := map[string]bool{"bd_research": true, "research": true} + + doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) + if err != nil { + t.Fatalf("in-scope select: %v", err) + } + if doc.TaskKey != "bd_research" { + t.Fatalf("want bd_research, got %s", doc.TaskKey) + } + + _, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) + if err == nil { + t.Fatal("expected wrong-workflow error") + } + var wrong *coordinator.ErrWrongWorkflow + if !errors.As(err, &wrong) { + t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err) + } + if !strings.Contains(wrong.Message, "切换") { + t.Fatalf("guidance should mention 切换, got %q", wrong.Message) + } +} + +func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) { + docs := []docgov.WorkflowDocumentSummary{ + {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, + } + doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil) + if err != nil { + t.Fatalf("legacy passthrough: %v", err) + } + if doc.TaskKey != "rd_proposal" { + t.Fatalf("want rd_proposal, got %s", doc.TaskKey) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index ac764b63..efe5eb72 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -290,6 +290,14 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { return } } + // 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。 + if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_DOC_WRONG_WORKFLOW", + "message": guidance, + }) + return + } } // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index a3f6285b..3dfdefd1 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -145,6 +145,17 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess if err == nil && settings != nil { sess.ActiveStageKey = settings.ActiveStageKey } + // 工作流作用域:把项目激活工作流注入会话上下文,供 L1 填槽与消费端收窄。 + // 查询失败按空处理(= 不过滤),不得沿用上一轮的过期键。 + if sess.UserCtx != nil { + if err == nil && settings != nil { + sess.UserCtx.Project.ActiveWorkflowKey = settings.ActiveWorkflowKey + } else { + sess.UserCtx.Project.ActiveWorkflowKey = "" + } + } + } else if sess.UserCtx != nil { + sess.UserCtx.Project.ActiveWorkflowKey = "" } scope, err := s.resolveChatMessageScope(ctx, sess.Project(), sess.ActiveStageKey) if err == nil && scope != nil {