Files
Agent-System/internal/server/chat_document_reader.go
T
weichenandCursor 0b1040ca7e 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>
2026-09-21 08:09:36 +00:00

255 lines
8.2 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"
)
// 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
}
docs, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
return nil, err
}
docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs)
// 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。
// 激活键由 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
}
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,
}, 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 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)
}
// 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 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)
}