fix(chat): 回填会话项目身份并给出阶段前置建议

协调器只解析上下文字符串项目 ID。会话只写数字项目 ID 时,任务项目恒为 0,对话注入和阶段门禁都不会生效。
This commit is contained in:
weichen
2026-09-28 15:36:37 +00:00
parent 8889011e5b
commit 54e92c973a
3 changed files with 242 additions and 4 deletions
+38 -4
View File
@@ -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,
+118
View File
@@ -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
}
}
+86
View File
@@ -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")
}
}