From 976f8d30b8ca3dc108294dfaf64a20a408bfdfe4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:11:47 +0000 Subject: [PATCH] feat(server): allow weighted cross-workflow reads after a view check Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked. Co-authored-by: Cursor --- internal/coordinator/context.go | 25 +++++ internal/coordinator/document_summary.go | 30 ++++- internal/coordinator/engine.go | 4 +- internal/intent/types.go | 1 + internal/server/chat_document_reader.go | 109 +++++++++++++++++-- internal/server/chat_document_reader_test.go | 46 ++++++++ internal/server/stage_access_helpers.go | 28 +++++ internal/workflowscope/score.go | 15 +++ 8 files changed, 242 insertions(+), 16 deletions(-) diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 18267a34..4f2864bd 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -70,6 +70,10 @@ type ReadableDocument struct { Version int Markdown string IsTemplateOnly bool + // WorkflowKey 是文档归属工作流。CrossWorkflow 为真时正文来自非激活流,仅只读。 + WorkflowKey string + ActiveWorkflow string + CrossWorkflow bool } // DocumentSummaryReader 由 HTTP 服务端按当前登录用户和项目绑定。 @@ -94,6 +98,27 @@ func WrongWorkflowGuidance(err error) string { return "" } +// ErrDocViewDenied 表示跨工作流文档存在,但当前用户无权查看。 +type ErrDocViewDenied struct { + Message string +} + +func (e *ErrDocViewDenied) Error() string { + if e == nil { + return "document view denied" + } + return e.Message +} + +// DocViewDeniedGuidance 若错误链是跨流查看拒绝,返回话术。 +func DocViewDeniedGuidance(err error) string { + var target *ErrDocViewDenied + 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 04bcd231..752fe055 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -31,6 +31,17 @@ func (e *CoordinatorEngine) handleDocumentSummary( if guidance := WrongWorkflowGuidance(err); guidance != "" { return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil } + if guidance := DocViewDeniedGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } + if owner := strings.TrimSpace(cand.FilledSlots[intent.SlotCrossWorkflowOwner]); owner != "" { + active := "" + if userCtx != nil { + active = userCtx.Project.ActiveWorkflowKey + } + note := fmt.Sprintf("您点名的《%s》属于【%s】,当前激活工作流为【%s】。当前项目台账里没有可读取的正文。", defaultStr(target, "该文档"), intent.WorkflowDisplayName(owner), intent.WorkflowDisplayName(active)) + return documentSummaryReply(userCtx, note, ReplySourceTemplate), nil + } return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil } if doc.IsTemplateOnly { @@ -40,10 +51,23 @@ func (e *CoordinatorEngine) handleDocumentSummary( return documentSummaryReply(userCtx, fmt.Sprintf("📄 《%s》正文为空,暂时无法总结。", defaultStr(doc.Title, doc.DocID)), ReplySourceTemplate), nil } - if reply := e.generateDocumentSummaryReply(ctx, doc, userCtx); reply != "" { - return documentSummaryReply(userCtx, reply, ReplySourcePiAgent), nil + reply := "" + source := ReplySourceTemplate + if generated := e.generateDocumentSummaryReply(ctx, doc, userCtx); generated != "" { + reply = generated + source = ReplySourcePiAgent + } else { + reply = fallbackDocumentSummary(doc) } - return documentSummaryReply(userCtx, fallbackDocumentSummary(doc), ReplySourceTemplate), nil + if doc.CrossWorkflow { + reply = crossWorkflowReadPreface(doc) + "\n\n" + reply + } + return documentSummaryReply(userCtx, reply, source), nil +} + +func crossWorkflowReadPreface(doc *ReadableDocument) string { + title := defaultStr(doc.Title, doc.DocID) + return fmt.Sprintf("以下是【%s】的《%s》,当前激活工作流为【%s】。", intent.WorkflowDisplayName(doc.WorkflowKey), title, intent.WorkflowDisplayName(doc.ActiveWorkflow)) } func documentSummaryReply(userCtx *UserProjectContext, reply, source string) *CoordinationResult { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 6524b319..5f5c580e 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -22,6 +22,7 @@ import ( "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowscope" ) // CoordinationStatus 协调裁决状态 @@ -959,7 +960,8 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { - if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey { + owner := intent.TaskKeyWorkflowOwner(taskKey) + if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) { guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) return &CoordinationResult{ Status: StatusClarificationRequired, diff --git a/internal/intent/types.go b/internal/intent/types.go index e655e0da..57491da4 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -161,6 +161,7 @@ const ( SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true" SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接) SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题 + SlotCrossWorkflowOwner = "cross_workflow_owner" // 只读跨流放行时的归属工作流 key // 长任务/运行槽位 SlotWorkDir = "work_dir" diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index ca32bdc6..932d95e4 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -12,6 +12,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowscope" ) // bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。 @@ -37,21 +38,37 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { return nil, err } - docs, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) + listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) if err != nil { return nil, err } - docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs) + docs := s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, listed) // 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。 // 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。 workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" { workflowKey = sess.UserCtx.Project.ActiveWorkflowKey } + if workflowscope.CurrentMode() == workflowscope.ModeWeighted && len(scopeTaskKeys) > 0 { + // 权重模式把被阶段过滤掉的他流台账加回候选,胜出后再做查看权限校验。 + docs = append(append([]docgov.WorkflowDocumentSummary{}, docs...), filterDocsOutsideTaskKeys(listed, scopeTaskKeys)...) + } doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys) if err != nil { return nil, err } + owner := intent.TaskKeyWorkflowOwner(doc.TaskKey) + cross := workflowKey != "" && owner != "" && owner != workflowKey && + (len(scopeTaskKeys) == 0 || !projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey)) + if cross && !s.crossWorkflowViewAllowed(ctx, projectID, userID, doc.TaskKey) { + title := doc.Title + if title == "" { + title = doc.TaskKey + } + return nil, &coordinator.ErrDocViewDenied{ + Message: fmt.Sprintf("您无权查看【%s】的《%s》。", intent.WorkflowDisplayName(owner), title), + } + } content, err := s.loadControlledDocumentContent(ctx, doc.DocID) if err != nil { return nil, err @@ -64,6 +81,9 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth Version: doc.Version, Markdown: content.Markdown, IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly, + WorkflowKey: owner, + ActiveWorkflow: workflowKey, + CrossWorkflow: cross, }, nil } } @@ -96,16 +116,16 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int return false, "" } owner := intent.TaskKeyWorkflowOwner(taskKey) - if owner == "" || owner == workflowKey { - return false, "" + if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) } - 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) + owner := intent.TaskKeyWorkflowOwner(taskKey) + if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) + } + return false, "" } // workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。 @@ -121,9 +141,9 @@ func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string] // errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 type errDocWrongWorkflow struct { - Title string - TaskKey string - OwnerWorkflow string + Title string + TaskKey string + OwnerWorkflow string ActiveWorkflow string } @@ -154,6 +174,71 @@ func selectReadableWorkflowDocument( target string, activeWorkflow string, scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { + if workflowscope.CurrentMode() == workflowscope.ModeWeighted && activeWorkflow != "" && len(scopeTaskKeys) > 0 && strings.TrimSpace(target) != "" { + return selectReadableWeighted(docs, target, activeWorkflow, scopeTaskKeys) + } + return selectReadableStrict(docs, target, activeWorkflow, scopeTaskKeys) +} + +func selectReadableWeighted( + docs []docgov.WorkflowDocumentSummary, + target string, + activeWorkflow string, + scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { + candidates := make([]workflowscope.Candidate, 0, len(docs)) + byID := make(map[string]docgov.WorkflowDocumentSummary, len(docs)) + for _, doc := range docs { + quality := workflowscope.BestQuality(target, doc.DocID, doc.TaskKey, doc.Title) + if quality <= 0 { + continue + } + owner := "" + if projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey) { + owner = activeWorkflow + } else if inferred := intent.TaskKeyWorkflowOwner(doc.TaskKey); inferred != "" { + owner = inferred + } + id := doc.DocID + if id == "" { + id = doc.TaskKey + } + candidates = append(candidates, workflowscope.Candidate{ + ID: id, + Label: doc.Title, + Owner: owner, + Quality: quality, + }) + byID[id] = doc + } + decision := workflowscope.Decide(candidates, activeWorkflow, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindInScope, workflowscope.KindCrossAllow: + doc := byID[decision.Winner.ID] + return &doc, nil + case workflowscope.KindCrossBlock: + doc := byID[decision.Winner.ID] + owner := decision.Winner.Owner + if owner == "" { + owner = intent.TaskKeyWorkflowOwner(doc.TaskKey) + } + return nil, (&errDocWrongWorkflow{ + Title: doc.Title, + TaskKey: doc.TaskKey, + OwnerWorkflow: owner, + ActiveWorkflow: activeWorkflow, + }).AsCoordinatorErr() + default: + return nil, fmt.Errorf("document not found in readable scope") + } +} + +func selectReadableStrict( + 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") diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 9db568f4..c277d0d1 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -7,6 +7,8 @@ import ( "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowscope" ) func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) { @@ -73,3 +75,47 @@ func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) { t.Fatalf("want rd_proposal, got %s", doc.TaskKey) } } + +func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + 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} + doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) + if err != nil { + t.Fatalf("weighted cross read: %v", err) + } + if doc.TaskKey != "rd_proposal" { + t.Fatalf("want rd_proposal, got %s", doc.TaskKey) + } + + inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) + if err != nil || inScope.TaskKey != "bd_research" { + t.Fatalf("in-scope still wins: %v %v", inScope, err) + } +} + +func TestCrossWorkflowTaskVisible(t *testing.T) { + open := &projectinstance.StageAccess{ + Restricted: true, + ViewableTaskKeys: map[string]bool{"bd_research": true}, + } + scope := map[string]bool{"bd_research": true, "bd_hidden": true} + if !crossWorkflowTaskVisible(open, scope, "rd_proposal") { + t.Fatal("foreign key should be readable when the caller can view the active instance") + } + if crossWorkflowTaskVisible(open, scope, "bd_hidden") { + t.Fatal("in-scope but not viewable must be denied") + } + closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}} + if crossWorkflowTaskVisible(closed, scope, "rd_proposal") { + t.Fatal("closed instance must deny cross read") + } + if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") { + t.Fatal("unrestricted access must allow") + } +} diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 3dfdefd1..ea1d8968 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -163,6 +163,34 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess } } +// crossWorkflowViewAllowed 判断他流文档是否允许当前用户只读。 +// 解析失败按 fail-closed 开关拒绝;可见集合为空(实例已关闭)拒绝; +// 任务键属于激活实例但不可见则拒绝;键不在激活实例快照内、且调用方仍能查看本实例文档时允许。 +func (s *Server) crossWorkflowViewAllowed(ctx context.Context, projectID, userID int64, taskKey string) bool { + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil { + return !s.failClosedAuthz() + } + _, scope := s.activeWorkflowScopeForDocs(ctx, projectID) + return crossWorkflowTaskVisible(access, scope, taskKey) +} + +func crossWorkflowTaskVisible(access *projectinstance.StageAccess, scope map[string]bool, taskKey string) bool { + if access == nil || !access.Restricted { + return true + } + if access.TaskKeyVisible(taskKey) { + return true + } + if len(access.ViewableTaskKeys) == 0 { + return false + } + if len(scope) > 0 && projectinstance.TaskKeyInScope(scope, taskKey) { + return false + } + return true +} + func (s *Server) filterWorkflowDocsByStageAccess( ctx context.Context, projectID, userID int64, diff --git a/internal/workflowscope/score.go b/internal/workflowscope/score.go index d73e9c75..a0f1c3c5 100644 --- a/internal/workflowscope/score.go +++ b/internal/workflowscope/score.go @@ -156,6 +156,21 @@ func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float return Decision{Kind: kind, Winner: best, Score: bestScore} } +// BlocksWrite 报告一个已确定归属的任务键在写路径上是否应拦截。 +// 归属未知或未激活时不拦截。 +func BlocksWrite(active, owner, id string) bool { + if strings.TrimSpace(active) == "" || strings.TrimSpace(owner) == "" || owner == active { + return false + } + decision := Decide([]Candidate{{ + ID: id, + Label: id, + Owner: owner, + Quality: QualityExact, + }}, active, ActionWrite) + return decision.Kind == KindCrossBlock +} + // BestQuality 取 query 对多个字段的最高匹配质量。 func BestQuality(query string, fields ...string) float64 { best := 0.0