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") + } +}