From 54e92c973af5406870cc3d30ca96e7d59c5d2fcf Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 15:36:37 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(chat):=20=E5=9B=9E=E5=A1=AB=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=A1=B9=E7=9B=AE=E8=BA=AB=E4=BB=BD=E5=B9=B6=E7=BB=99?= =?UTF-8?q?=E5=87=BA=E9=98=B6=E6=AE=B5=E5=89=8D=E7=BD=AE=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协调器只解析上下文字符串项目 ID。会话只写数字项目 ID 时,任务项目恒为 0,对话注入和阶段门禁都不会生效。 --- internal/chat/session.go | 42 ++++++++- internal/chat/workflow_guidance.go | 118 ++++++++++++++++++++++++ internal/chat/workflow_guidance_test.go | 86 +++++++++++++++++ 3 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 internal/chat/workflow_guidance.go create mode 100644 internal/chat/workflow_guidance_test.go diff --git a/internal/chat/session.go b/internal/chat/session.go index 7ae7f034..3cdb42ba 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "strings" "sync" "time" @@ -94,11 +95,44 @@ func (s *Session) LastAccessed() time.Time { return s.LastAccessedAt } -// SetProjectID 设置当前会话绑定的项目 ID。 +// SetProjectID 设置当前会话绑定的项目 ID,并同步协调器读取的 UserCtx.Project.ID。 +// 协调器派发任务只解析字符串项目 ID;只改会话字段会让对话注入和阶段门禁看到 0。 func (s *Session) SetProjectID(pid int64) { s.mu.Lock() defer s.mu.Unlock() s.ProjectID = pid + s.syncProjectIDLocked() +} + +// bindCoordinatorIdentity 在每轮派发前用已授权的会话身份覆盖协调器上下文。 +// 会话 ID 供工作区追溯;数字用户 ID 供阶段门禁。阶段键故意不收窄对话采集, +// 个人会话与项目成员共享对话都进入任务上下文,只滤掉私密与噪声。 +func (s *Session) bindCoordinatorIdentity() { + if s == nil || s.UserCtx == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.syncProjectIDLocked() + if s.UserID > 0 && parseUserID(s.UserCtx.User.ID) <= 0 { + s.UserCtx.User.ID = strconv.FormatInt(s.UserID, 10) + } + if s.ID != "" { + s.UserCtx.ChatSessionID = s.ID + } +} + +func (s *Session) syncProjectIDLocked() { + if s.UserCtx == nil { + return + } + if s.ProjectID > 0 { + s.UserCtx.Project.ID = strconv.FormatInt(s.ProjectID, 10) + return + } + if _, err := strconv.ParseInt(strings.TrimSpace(s.UserCtx.Project.ID), 10, 64); err == nil { + s.UserCtx.Project.ID = "" + } } // Project 读取当前会话绑定的项目 ID。 @@ -465,9 +499,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40) } s.EnsureDocResolver() - if s.UserCtx != nil { - s.UserCtx.ChatSessionID = s.ID - } + s.bindCoordinatorIdentity() coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx) if err != nil { if streamer != nil { @@ -506,6 +538,8 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str s.UserCtx = coordRes.UpdatedContext } + s.augmentWorkflowGuidance(ctx, userInput, coordRes) + if coordRes.ClearExpectation { _ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{ ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true, diff --git a/internal/chat/workflow_guidance.go b/internal/chat/workflow_guidance.go new file mode 100644 index 00000000..bd3e64c3 --- /dev/null +++ b/internal/chat/workflow_guidance.go @@ -0,0 +1,118 @@ +package chat + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/workflowdef" +) + +const stageAdvanceGuidance = "该文档所属阶段尚未激活。请先完成当前阶段并推进后再编制。" + +const researchFirstHint = "当前还没有完成调研报告。调研是可行性评估的前置输入。是否先进行调研?" + +func stageAccessDenied(reply string) bool { + return strings.Contains(reply, "workflow stage access denied") || strings.Contains(reply, "stage access denied") +} + +// workflowPrerequisiteHint 在用户要写可行性评估、而调研仍是模板或尚未建账时,建议先调研。 +// docs 为 nil 表示台账未知,不猜测。 +func workflowPrerequisiteHint(userInput string, docs []docgov.WorkflowDocumentSummary, known bool) (string, string) { + if !known || !strings.Contains(userInput, "可行性") { + return "", "" + } + ready, key := researchMaterialReady(docs) + if ready { + return "", "" + } + if key == "" { + key = "bd_research" + } + return researchFirstHint, key +} + +func researchMaterialReady(docs []docgov.WorkflowDocumentSummary) (ready bool, taskKey string) { + for _, doc := range docs { + if !workflowdef.SameTaskKey(doc.TaskKey, "research") { + continue + } + taskKey = doc.TaskKey + if !doc.IsTemplateOnly { + return true, taskKey + } + } + return false, taskKey +} + +func researchFirstExpectation(taskKey string) *intent.PendingExpectation { + if taskKey == "" { + taskKey = "bd_research" + } + opt := intent.ExpectationOption{ + OptionID: "1", + Title: "先进行调研", + Description: "先生成调研报告,再编写可行性评估", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{intent.SlotTargetTask: taskKey}, + } + talk := intent.ExpectationOption{ + OptionID: "2", + Title: "先补充信息,暂不调研", + Intent: intent.IntentCasualChat, + } + return &intent.PendingExpectation{ + ExpectationID: fmt.Sprintf("research-first-%d", time.Now().UnixNano()), + ExpectedIntent: intent.IntentTriggerTask, + TargetAction: "confirm_trigger_task", + Prompt: researchFirstHint, + AffirmativeWord: "好", + DeclineWord: "先不", + ExpiresAt: time.Now().Add(30 * time.Minute), + ContextSnippet: "可行性评估的前置调研尚未完成", + DefaultOption: &opt, + Options: []intent.ExpectationOption{opt, talk}, + AllowAffirmative: true, + CreatedAt: time.Now(), + } +} + +func (s *Session) listWorkflowDocs(ctx context.Context) ([]docgov.WorkflowDocumentSummary, bool) { + if s == nil || s.Actions == nil || s.Actions.DocGov == nil || s.Project() <= 0 { + return nil, false + } + docs, err := s.Actions.DocGov.ListWorkflowDocumentsByProject(ctx, s.Project()) + if err != nil { + return nil, false + } + return docs, true +} + +func (s *Session) augmentWorkflowGuidance(ctx context.Context, userInput string, res *coordinator.CoordinationResult) { + if s == nil || res == nil { + return + } + if stageAccessDenied(res.NaturalReply) && !strings.Contains(res.NaturalReply, stageAdvanceGuidance) { + res.NaturalReply = strings.TrimSpace(res.NaturalReply) + "\n\n" + stageAdvanceGuidance + } + docs, known := s.listWorkflowDocs(ctx) + hint, taskKey := workflowPrerequisiteHint(userInput, docs, known) + if hint == "" { + return + } + if !strings.Contains(res.NaturalReply, "是否先进行调研") { + res.NaturalReply = strings.TrimSpace(res.NaturalReply) + "\n\n" + hint + } + if res.ClearExpectation || res.NextExpectation != nil { + return + } + exp := researchFirstExpectation(taskKey) + res.NextExpectation = exp + if res.UpdatedContext != nil && res.UpdatedContext.PendingExpectation == nil { + res.UpdatedContext.PendingExpectation = exp + } +} diff --git a/internal/chat/workflow_guidance_test.go b/internal/chat/workflow_guidance_test.go new file mode 100644 index 00000000..3ae4ae5a --- /dev/null +++ b/internal/chat/workflow_guidance_test.go @@ -0,0 +1,86 @@ +package chat + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" +) + +func TestSetProjectIDSyncsCoordinatorProject(t *testing.T) { + sess := NewSession("sess-p6022-u74", &coordinator.UserProjectContext{}, nil, nil) + sess.SetProjectID(6022) + if sess.Project() != 6022 { + t.Fatalf("session project=%d", sess.Project()) + } + if sess.UserCtx.Project.ID != "6022" { + t.Fatalf("coordinator project=%q", sess.UserCtx.Project.ID) + } + sess.SetProjectID(0) + if sess.UserCtx.Project.ID != "" { + t.Fatalf("cleared project=%q", sess.UserCtx.Project.ID) + } +} + +func TestBindCoordinatorIdentityUsesNumericUser(t *testing.T) { + sess := NewSession("sess-p6022-u74", &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "u_sess-p6022-u74"}, + }, nil, nil) + sess.UserID = 74 + sess.SetProjectID(6022) + sess.bindCoordinatorIdentity() + if sess.UserCtx.User.ID != "74" { + t.Fatalf("user=%q", sess.UserCtx.User.ID) + } + if sess.UserCtx.ChatSessionID != "sess-p6022-u74" { + t.Fatalf("session=%q", sess.UserCtx.ChatSessionID) + } + if sess.UserCtx.Project.ID != "6022" { + t.Fatalf("project=%q", sess.UserCtx.Project.ID) + } +} + +func TestWorkflowPrerequisiteHintSuggestsResearch(t *testing.T) { + docs := []docgov.WorkflowDocumentSummary{ + {TaskKey: "bd_research", IsTemplateOnly: true, Title: "调研报告"}, + {TaskKey: "bd_feasibility_dev", IsTemplateOnly: true}, + } + hint, key := workflowPrerequisiteHint("开始生成设计开发可行性评估报告", docs, true) + if hint == "" || key != "bd_research" { + t.Fatalf("hint=%q key=%q", hint, key) + } + ready, _ := researchMaterialReady([]docgov.WorkflowDocumentSummary{ + {TaskKey: "bd_research", IsTemplateOnly: false}, + }) + if !ready { + t.Fatal("finished research should be ready") + } + if h, _ := workflowPrerequisiteHint("今天天气怎么样", docs, true); h != "" { + t.Fatalf("unrelated hint=%q", h) + } + if h, _ := workflowPrerequisiteHint("开始生成可行性评估", nil, false); h != "" { + t.Fatalf("unknown ledger hint=%q", h) + } +} + +func TestAugmentWorkflowGuidanceSetsExpectationAndStageNote(t *testing.T) { + sess := NewSession("sess-1", &coordinator.UserProjectContext{}, nil, nil) + res := &coordinator.CoordinationResult{ + NaturalReply: "任务执行失败: bizdev: workflow stage access denied: task \"bd_feasibility_dev\"", + UpdatedContext: sess.UserCtx, + } + sess.augmentWorkflowGuidance(context.Background(), "开始生成设计开发可行性评估报告", res) + if !strings.Contains(res.NaturalReply, stageAdvanceGuidance) { + t.Fatalf("reply=%q", res.NaturalReply) + } + exp := researchFirstExpectation("bd_research") + if exp.DefaultOption == nil || exp.DefaultOption.Slots[intent.SlotTargetTask] != "bd_research" { + t.Fatalf("expectation=%+v", exp) + } + if !exp.AllowAffirmative { + t.Fatal("affirmative should start research") + } +} From 721af4ccb84ff8c1db9d4e8ad9ea8cabb83b75da Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 15:36:38 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(squad):=20=E5=8C=BA=E5=88=86=E6=9C=AA?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=AF=B9=E8=AF=9D=E7=9A=84=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E5=B9=B6=E9=99=84=E4=B8=8A=E9=87=87=E9=9B=86=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 占位说明不再把开关关闭、项目未绑定和本轮没有对话包混成一句,避免把未采集误写成对话已被滤掉。 --- internal/squad/dialogue_context_test.go | 75 ++++++++++++++++++++--- internal/squad/workspace.go | 12 ++-- internal/squad/workspace_context_build.go | 46 ++++++++++++-- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/internal/squad/dialogue_context_test.go b/internal/squad/dialogue_context_test.go index 20328c09..fa6a1f65 100644 --- a/internal/squad/dialogue_context_test.go +++ b/internal/squad/dialogue_context_test.go @@ -13,16 +13,20 @@ func TestPrepareWorkspaceWritesDialogueContext(t *testing.T) { dir := t.TempDir() engine := squad.NewWorkspaceEngine() ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ - WorkDir: dir, - TaskKey: "system_reqs", - StageTitle: "系统需求", - ProjectName: "Demo", + WorkDir: dir, + TaskKey: "system_reqs", + StageTitle: "系统需求", + ProjectName: "Demo", CustomerName: "Acme", DialoguePackage: &squad.DialoguePackage{ - DigestMD: "# 项目对话纪要\n\n- **2026-09-24**: 张工: 器械类型眼科OCT\n", - TurnsMD: "# turns\n\n[2026-09-24 15:00] 张工(engineer/member) 用户: 器械类型:眼科OCT\n", - FactsJSON: []byte(`[{"key":"device_type","value":"眼科OCT"}]`), - ConfirmationsMD: "# 裁决\n\n无冲突\n", + DigestMD: "# 项目对话纪要\n\n- **2026-09-24**: 张工: 器械类型眼科OCT\n", + TurnsMD: "# turns\n\n[2026-09-24 15:00] 张工(engineer/member) 用户: 器械类型:眼科OCT\n", + FactsJSON: []byte(`[{"key":"device_type","value":"眼科OCT"}]`), + ConfirmationsMD: "# 裁决\n\n无冲突\n", + TurnCount: 2, + ExcludedPrivate: 1, + ExcludedNoAccess: 0, + ExcludedNoise: 3, }, WorkflowSteps: []squad.StepSpec{{ ID: "s1", Title: "整理", Role: "organizer", Mode: "write", @@ -45,8 +49,63 @@ func TestPrepareWorkspaceWritesDialogueContext(t *testing.T) { if !strings.Contains(string(readme), "collab_dialogue") { t.Fatalf("README missing collab_dialogue: %s", readme) } + dlgReadme, _ := os.ReadFile(filepath.Join(dir, "context", "dialogue", "README.md")) + if !strings.Contains(string(dlgReadme), "采集统计:保留 2 轮;滤除私密 1、阶段不可见 0、噪声 3") { + t.Fatalf("dialogue README missing collection stats: %s", dlgReadme) + } taskMD, _ := os.ReadFile(filepath.Join(dir, "tasks", "01_organizer", "TASK.md")) if !strings.Contains(string(taskMD), "context/dialogue/") { t.Fatalf("TASK.md missing dialogue nav: %s", taskMD) } } + +func TestPrepareWorkspaceExplainsMissingDialogue(t *testing.T) { + t.Setenv("DIALOGUE_CTX_INJECTION", "") + unbound := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{}) + readme, err := os.ReadFile(filepath.Join(unbound, "context", "dialogue", "README.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(readme), "任务未绑定项目") { + t.Fatalf("unbound readme=%s", readme) + } + if _, err := os.Stat(filepath.Join(unbound, "context", "dialogue", "turns.md")); err == nil { + t.Fatal("unbound project must not pretend turns were filtered") + } + + bound := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{ProjectID: 6022}) + readme, err = os.ReadFile(filepath.Join(bound, "context", "dialogue", "README.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(readme), "没有对话包") { + t.Fatalf("bound readme=%s", readme) + } + + t.Setenv("DIALOGUE_CTX_INJECTION", "off") + disabled := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{ProjectID: 6022}) + readme, err = os.ReadFile(filepath.Join(disabled, "context", "dialogue", "README.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(readme), "开关已关闭") { + t.Fatalf("disabled readme=%s", readme) + } +} + +func prepareDialogueWorkspace(t *testing.T, req squad.WorkspacePrepareRequest) string { + t.Helper() + req.WorkDir = t.TempDir() + req.TaskKey = "research" + req.StageTitle = "调研报告" + req.ProjectName = "测试项目" + req.CustomerName = "客户" + req.WorkflowSteps = []squad.StepSpec{{ + ID: "s1", Title: "整理", Role: "organizer", Mode: "write", + SubWorkspace: "tasks/01_organizer", Status: "pending", + }} + if _, err := squad.NewWorkspaceEngine().PrepareWorkspace(req); err != nil { + t.Fatal(err) + } + return req.WorkDir +} diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index 4fc179ac..ff271db5 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -41,10 +41,14 @@ type WorkspacePrepareRequest struct { // DialoguePackage is the squad-facing dialogue injection payload. type DialoguePackage struct { - DigestMD string - TurnsMD string - FactsJSON []byte - ConfirmationsMD string + DigestMD string + TurnsMD string + FactsJSON []byte + ConfirmationsMD string + TurnCount int + ExcludedPrivate int + ExcludedNoAccess int + ExcludedNoise int } // Workspace 表示已初始化的工作区句柄 diff --git a/internal/squad/workspace_context_build.go b/internal/squad/workspace_context_build.go index 7ad44fe7..487908b4 100644 --- a/internal/squad/workspace_context_build.go +++ b/internal/squad/workspace_context_build.go @@ -182,7 +182,7 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar }) // 8. dialogue/ — project member dialogue (digest + turns + facts) - if err := e.writeDialogueContext(ctxDir, req.DialoguePackage, &contextItems); err != nil { + if err := e.writeDialogueContext(ctxDir, req.ProjectID, req.DialoguePackage, &contextItems); err != nil { return err } @@ -210,13 +210,13 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644) } -func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePackage, items *[]ContextItem) error { +func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, projectID int64, pkg *DialoguePackage, items *[]ContextItem) error { dlgDir := filepath.Join(ctxDir, "dialogue") if err := os.MkdirAll(dlgDir, 0o755); err != nil { return err } - if pkg == nil { - _ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目对话说明\n\n本任务未注入项目成员对话上下文(开关关闭或无可共享对话)。\n"), 0o644) + if reason := dialogueOmitReason(projectID, pkg); reason != "" { + _ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte(reason), 0o644) return nil } digest := pkg.DigestMD @@ -239,7 +239,9 @@ func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePacka _ = os.WriteFile(filepath.Join(dlgDir, "turns.md"), []byte(turns), 0o644) _ = os.WriteFile(filepath.Join(dlgDir, "facts.json"), facts, 0o644) _ = os.WriteFile(filepath.Join(dlgDir, "CONFIRMATIONS.md"), []byte(confirmations), 0o644) - _ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目成员对话上下文\n\n- `digest.md`:按日纪要\n- `turns.md`:近窗带元数据原文\n- `facts.json`:对话沉淀事实候选\n- `CONFIRMATIONS.md`:冲突裁决审计\n\n权威度 `collab_dialogue`:低于黑板与已批准文档。\n"), 0o644) + readme := "# 项目成员对话上下文\n\n- `digest.md`:按日纪要\n- `turns.md`:近窗带元数据原文\n- `facts.json`:对话沉淀事实候选\n- `CONFIRMATIONS.md`:冲突裁决审计\n\n权威度 `collab_dialogue`:低于黑板与已批准文档。\n" + readme += dialogueCollectionStats(pkg) + _ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte(readme), 0o644) *items = append(*items, ContextItem{ File: "context/dialogue/", @@ -249,3 +251,37 @@ func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePacka }) return nil } + +func dialogueInjectionEnabled() bool { + switch strings.TrimSpace(strings.ToLower(os.Getenv("DIALOGUE_CTX_INJECTION"))) { + case "off", "false", "0", "no": + return false + default: + return true + } +} + +// dialogueOmitReason 区分未注入的三种原因。已有对话包时返回空,调用方写入正文。 +func dialogueOmitReason(projectID int64, pkg *DialoguePackage) string { + if pkg != nil { + return "" + } + if !dialogueInjectionEnabled() { + return "# 项目对话说明\n\n未注入项目成员对话:对话注入开关已关闭(DIALOGUE_CTX_INJECTION)。\n" + } + if projectID <= 0 { + return "# 项目对话说明\n\n未注入项目成员对话:任务未绑定项目,无法收集个人会话与项目成员共享对话。\n" + } + return "# 项目对话说明\n\n未注入项目成员对话:项目已绑定,但本轮没有对话包(采集失败或调用方未传入)。这不表示对话已被判定为无用信息。\n" +} + +func dialogueCollectionStats(pkg *DialoguePackage) string { + if pkg == nil { + return "" + } + if pkg.TurnCount == 0 && pkg.ExcludedPrivate == 0 && pkg.ExcludedNoAccess == 0 && pkg.ExcludedNoise == 0 { + return "" + } + return fmt.Sprintf("\n采集统计:保留 %d 轮;滤除私密 %d、阶段不可见 %d、噪声 %d。仅当这些过滤后仍有内容时才写入 turns.md。\n", + pkg.TurnCount, pkg.ExcludedPrivate, pkg.ExcludedNoAccess, pkg.ExcludedNoise) +} From 32568ed13e01d36d897955c87315e573c9b012df Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 15:36:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(web):=20=E9=A1=B9=E7=9B=AE=E6=88=90?= =?UTF-8?q?=E5=91=98=E5=8F=AF=E8=A7=81=E6=9C=AC=E9=A1=B9=E7=9B=AE=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=8F=97=E6=8E=A7=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已打开项目的流程受控文档按成员基线展示。单文档指定人、角色白名单和私密隔离仍然优先。 --- web/src/App.tsx | 4 +++- web/src/engine/rbac.test.ts | 21 ++++++++++++++++++++- web/src/engine/rbac.ts | 19 ++++++++++++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1fa2b7dd..4b91b4a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -472,7 +472,9 @@ export default function App() { // 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线) const visibleDeliverables = useMemo(() => { - return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles); + return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles, { + projectMember: true, + }); }, [currentProjectDeliverables, currentUser, roles]); // 单文档可见性覆盖保存处理 diff --git a/web/src/engine/rbac.test.ts b/web/src/engine/rbac.test.ts index cdb9a49f..9152be5f 100644 --- a/web/src/engine/rbac.test.ts +++ b/web/src/engine/rbac.test.ts @@ -112,8 +112,21 @@ test('用例 3: 常规工作流基线可见性 (workflow:view_all) 判定', () = // 工程师有 workflow:view_all -> 可见 assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, normalDoc), true); - // 外部审计员无 workflow:view_all 且不是责任人 -> 不可见 + // 外部审计员无 workflow:view_all 且不是项目成员 -> 不可见 assert.equal(isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, normalDoc), false); + + // 同一访客作为项目成员打开本项目 -> 可见流程受控文档 + assert.equal( + isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, normalDoc, { projectMember: true }), + true, + ); + const memberVisible = filterVisibleDeliverablesWithRBAC( + [normalDoc], + sampleUsers.guest, + DEFAULT_ROLES, + { projectMember: true }, + ); + assert.equal(memberVisible.length, 1); }); test('用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截)', () => { @@ -138,6 +151,12 @@ test('用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可 // 王总监虽为研发总监,但非三权责任人 -> 被单文档覆盖拦截 (不可见) assert.equal(isDeliverableVisible(sampleUsers.director, DEFAULT_ROLES, restrictedDoc), false); + // 项目成员身份不覆盖「仅责任人」收紧 + assert.equal( + isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, restrictedDoc, { projectMember: true }), + false, + ); + // 系统管理员超级权限仍可见 assert.equal(isDeliverableVisible(sampleUsers.admin, DEFAULT_ROLES, restrictedDoc), true); }); diff --git a/web/src/engine/rbac.ts b/web/src/engine/rbac.ts index a3aab7f0..17d98da9 100644 --- a/web/src/engine/rbac.ts +++ b/web/src/engine/rbac.ts @@ -108,12 +108,14 @@ export function hasPermission( * - designated_only: 仅责任人可见 (非责任人已在步骤4排除,直接拒绝) * - role_whitelist: 仅白名单内角色可见 * - public: 全员可见 - * 6. 回退继承工作流基线:用户角色需具备 workflow:view_all 权限 + * 6. 项目成员:已打开的项目内,流程受控文档对 active 成员可见(非成员不传 projectMember) + * 7. 回退继承工作流基线:用户角色需具备 workflow:view_all 权限 */ export function isDeliverableVisible( user: UserProfile, roles: RoleDefinition[], - doc: DeliverableItem + doc: DeliverableItem, + options?: { projectMember?: boolean }, ): boolean { // 步骤 1:个人私密文档 (private) 绝对隔离 if (doc.category === 'private') { @@ -166,7 +168,13 @@ export function isDeliverableVisible( } } - // 步骤 6:回退继承工作流默认基线规则(文档可见性仍用角色矩阵作 UI 展示兜底) + // 步骤 6:项目成员可见本项目流程受控文档。 + // 非成员仍回退到 workflow:view_all;单文档覆盖(步骤 5)优先于成员基线。 + if (doc.category === 'workflow' && options?.projectMember) { + return true; + } + + // 步骤 7:回退继承工作流默认基线规则(文档可见性仍用角色矩阵作 UI 展示兜底) return hasPermission(user, roles, 'workflow:view_all', capabilitiesFromRole(roles, user.roleId)); } @@ -176,9 +184,10 @@ export function isDeliverableVisible( export function filterVisibleDeliverablesWithRBAC( deliverables: DeliverableItem[], user: UserProfile, - roles: RoleDefinition[] + roles: RoleDefinition[], + options?: { projectMember?: boolean }, ): DeliverableItem[] { - return deliverables.filter((doc) => isDeliverableVisible(user, roles, doc)); + return deliverables.filter((doc) => isDeliverableVisible(user, roles, doc, options)); } /** From 16f33b5b9d98a238f04e82f0c135cf671826a87d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 15:44:04 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(chat):=20=E7=94=A8=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=94=AE=E5=88=A4=E6=96=AD=E8=B0=83=E7=A0=94?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=EF=BC=8C=E9=81=BF=E5=85=8D=E8=B6=8A=E7=95=8C?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat 不能引用 workflowdef。调研报告只认 research 与 bd_research 两个现用键。 --- internal/chat/workflow_guidance.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/chat/workflow_guidance.go b/internal/chat/workflow_guidance.go index bd3e64c3..0840d05d 100644 --- a/internal/chat/workflow_guidance.go +++ b/internal/chat/workflow_guidance.go @@ -9,7 +9,6 @@ import ( "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" - "chat-agent-system/internal/workflowdef" ) const stageAdvanceGuidance = "该文档所属阶段尚未激活。请先完成当前阶段并推进后再编制。" @@ -36,9 +35,18 @@ func workflowPrerequisiteHint(userInput string, docs []docgov.WorkflowDocumentSu return researchFirstHint, key } +func isResearchTaskKey(key string) bool { + switch strings.TrimSpace(key) { + case "research", "bd_research": + return true + default: + return false + } +} + func researchMaterialReady(docs []docgov.WorkflowDocumentSummary) (ready bool, taskKey string) { for _, doc := range docs { - if !workflowdef.SameTaskKey(doc.TaskKey, "research") { + if !isResearchTaskKey(doc.TaskKey) { continue } taskKey = doc.TaskKey