Files
Agent-System/internal/server/chat_document_reader.go
T
weichenandCursor 976f8d30b8 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 <cursoragent@cursor.com>
2026-09-21 09:11:47 +00:00

340 lines
11 KiB
Go

package server
import (
"context"
"fmt"
"strings"
"unicode"
"chat-agent-system/internal/auth"
"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"
"chat-agent-system/internal/workflowscope"
)
// bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。
// 读取前先应用与文档列表/正文 API 相同的阶段权限过滤。
func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth.UserAccount) {
if sess == nil || sess.UserCtx == nil {
return
}
sess.UserCtx.DocumentSummaryReader = nil
projectID := sess.Project()
userID := int64(0)
if user != nil {
userID = user.ID
}
if projectID <= 0 || userID <= 0 || s.gov == nil {
return
}
sess.UserCtx.DocumentSummaryReader = func(ctx context.Context, target string) (*coordinator.ReadableDocument, error) {
if s.authorizer == nil {
return nil, fmt.Errorf("project authorization unavailable")
}
if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil {
return nil, err
}
listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
return nil, err
}
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
}
return &coordinator.ReadableDocument{
DocID: doc.DocID,
Title: doc.Title,
TaskKey: doc.TaskKey,
LifecycleState: string(doc.LifecycleState),
Version: doc.Version,
Markdown: content.Markdown,
IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly,
WorkflowKey: owner,
ActiveWorkflow: workflowKey,
CrossWorkflow: cross,
}, nil
}
}
// 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 workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
return false, ""
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
return false, ""
}
// 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
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 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")
}
targetKey := normalizeDocumentLookupKey(target)
if targetKey == "" {
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]
}
}
for i := range docs {
keys := []string{
normalizeDocumentLookupKey(docs[i].DocID),
normalizeDocumentLookupKey(docs[i].TaskKey),
normalizeDocumentLookupKey(docs[i].Title),
}
for _, key := range keys {
if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) {
return &docs[i]
}
}
}
return nil
}
func normalizeDocumentLookupKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.NewReplacer("收集", "", "采集", "", "正文", "", "文档", "", "《", "", "》", "").Replace(value)
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) || unicode.IsPunct(r) {
return -1
}
return r
}, value)
}