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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 服务中记录运行实例
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user