From 5ba9bbbe11d5ee4fdc5682f1e7d7a5ed003c2b6a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:24:26 +0000 Subject: [PATCH] feat(coordinator): smart confirm gate before TRIGGER_TASK execution Ask via PendingExpectation for weak-write verbs, mid confidence, or stage-mismatched doc tasks instead of running blindly. Co-authored-by: Cursor --- internal/coordinator/confirm_gate.go | 203 ++++++++++++++++++++++ internal/coordinator/confirm_gate_test.go | 91 ++++++++++ internal/coordinator/engine.go | 5 + 3 files changed, 299 insertions(+) create mode 100644 internal/coordinator/confirm_gate.go create mode 100644 internal/coordinator/confirm_gate_test.go diff --git a/internal/coordinator/confirm_gate.go b/internal/coordinator/confirm_gate.go new file mode 100644 index 00000000..b369a032 --- /dev/null +++ b/internal/coordinator/confirm_gate.go @@ -0,0 +1,203 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" + "chat-agent-system/internal/projectinstance" +) + +// taskDisplayTitle 用户可见的交付物标题(确认话术用)。 +func taskDisplayTitle(taskKey string) string { + switch strings.TrimSpace(taskKey) { + case "intake", "bd_intake": + return "用户初始需求收集表" + case "user_requirements", "bd_user_requirements": + return "用户需求表" + case "research", "bd_research": + return "技术与市场调研报告" + case "feasibility_reg", "bd_feasibility_reg": + return "法规与注册论证报告" + case "feasibility_dev", "bd_feasibility_dev": + return "设计开发可行性评估" + case "prelim_design", "bd_prelim_design": + return "系统初步设计方案" + case "cdo_quote", "bd_cdo_quote": + return "CDO报价单" + default: + if taskKey == "" { + return "目标交付物" + } + return taskKey + } +} + +// confirmGate 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信时先下发 PendingExpectation,不盲目执行。 +// 返回非 nil 时 handleTriggerTask 应直接返回该结果。 +func (e *CoordinatorEngine) confirmGate( + ctx context.Context, + cand *intent.CandidateIntent, + userCtx *UserProjectContext, + taskKey string, +) *CoordinationResult { + if cand == nil || userCtx == nil { + return nil + } + if cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + return nil + } + + needsConfirm := cand.FilledSlots[intent.SlotNeedsConfirmation] == "true" + midConfidence := cand.Confidence >= 0.6 && cand.Confidence <= 0.85 + + stageMismatch := false + if e != nil && e.instSvc != nil && e.instSvc.Available() { + projectID := parseDecimalID(userCtx.Project.ID) + userID := parseDecimalID(userCtx.User.ID) + if projectID > 0 && userID > 0 && taskKey != "" { + isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID + d := e.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun, isCreator) + if !d.Allowed && d.Code == projectinstance.AccessCodeNotCurrentStage { + stageMismatch = true + } + } + } + + if !needsConfirm && !stageMismatch && !midConfidence { + return nil + } + + title := taskDisplayTitle(taskKey) + exp := buildTriggerConfirmExpectation(taskKey, title, userCtx, stageMismatch) + userCtx.applyPending(exp) + + prompt := formatTriggerConfirmPrompt(title, needsConfirm, stageMismatch, midConfidence, userCtx) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: prompt, + NaturalReply: prompt, + UpdatedContext: userCtx, + NextExpectation: exp, + } +} + +func buildTriggerConfirmExpectation( + taskKey, title string, + userCtx *UserProjectContext, + stageMismatch bool, +) *intent.PendingExpectation { + optRun := intent.ExpectationOption{ + OptionID: "1", + Title: fmt.Sprintf("开始整理并编写《%s》", title), + Description: "确认后启动正式文档任务,写入受控交付物", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{ + intent.SlotTargetTask: taskKey, + }, + } + // 阶段不匹配时:默认改为当前阶段摸底表,避免再次撞门禁。 + if stageMismatch { + stage := userCtx.Project.CurrentStage + if stage == "" || stage == maturity.StageV01_Triage { + optRun = intent.ExpectationOption{ + OptionID: "1", + Title: "改为编制当前阶段《用户初始需求收集表》", + Description: "当前仍在 V0.1 摸底期,先完成初始需求收集更合适", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{ + intent.SlotTargetTask: "intake", + }, + } + } + } + + optTalk := intent.ExpectationOption{ + OptionID: "2", + Title: "先在对话中梳理,暂不启动文档任务", + Description: "继续对话细化需求要点,稍后再决定是否生成正式文档", + Intent: intent.IntentCasualChat, + } + + options := []intent.ExpectationOption{optRun, optTalk} + if stageMismatch { + options = append(options, intent.ExpectationOption{ + OptionID: "3", + Title: "先推进到下一成熟度阶段", + Description: "确认阶段准入后再编制后续文档", + Intent: intent.IntentAdvanceStage, + Slots: map[string]string{ + intent.SlotTargetStage: string(nextStageAfter(userCtx.Project.CurrentStage)), + }, + }) + } + + return &intent.PendingExpectation{ + ExpectationID: fmt.Sprintf("confirm-trigger-%d", time.Now().UnixNano()), + ExpectedIntent: intent.IntentTriggerTask, + TargetAction: "confirm_trigger_task", + Prompt: fmt.Sprintf("是否开始编写《%s》?", title), + AffirmativeWord: "好", + DeclineWord: "先不", + ExpiresAt: time.Now().Add(30 * time.Minute), + ContextSnippet: fmt.Sprintf("候选任务:%s(%s)", title, taskKey), + DefaultOption: &optRun, + Options: options, + AllowAffirmative: true, + CreatedAt: time.Now(), + } +} + +func nextStageAfter(stage maturity.MaturityStage) maturity.MaturityStage { + switch stage { + case maturity.StageV01_Triage, "": + return maturity.StageV05_Feasibility + case maturity.StageV05_Feasibility: + return maturity.StageV10_Quoting + default: + return maturity.StageV05_Feasibility + } +} + +func formatTriggerConfirmPrompt( + title string, + needsConfirm, stageMismatch, midConfidence bool, + userCtx *UserProjectContext, +) string { + var b strings.Builder + switch { + case stageMismatch: + stageLabel := string(userCtx.Project.CurrentStage) + if stageLabel == "" { + stageLabel = "初期" + } + b.WriteString(fmt.Sprintf( + "您提到的《%s》不在当前活动阶段(%s)。为避免启动后失败,请先确认下一步:\n\n", + title, stageLabel, + )) + b.WriteString("1. 改为编制当前阶段《用户初始需求收集表》(推荐,回复「好」或「1」)\n") + b.WriteString("2. 先在对话中梳理,暂不启动文档任务\n") + b.WriteString("3. 先推进到下一成熟度阶段\n") + case needsConfirm: + b.WriteString(fmt.Sprintf( + "我理解您希望先整理需求。更稳妥的做法是开始整理并编写《%s》。请确认:\n\n", + title, + )) + b.WriteString(fmt.Sprintf("1. 开始整理并编写《%s》(推荐,回复「好」或「1」)\n", title)) + b.WriteString("2. 先在对话中梳理,暂不启动文档任务\n") + default: // midConfidence + b.WriteString(fmt.Sprintf( + "我倾向于为您启动《%s》的编制任务,但表述略有歧义。请确认:\n\n", + title, + )) + b.WriteString(fmt.Sprintf("1. 确认启动《%s》(回复「好」或「1」)\n", title)) + b.WriteString("2. 先在对话中说明,暂不启动\n") + } + _ = midConfidence + return b.String() +} diff --git a/internal/coordinator/confirm_gate_test.go b/internal/coordinator/confirm_gate_test.go new file mode 100644 index 00000000..307b51fb --- /dev/null +++ b/internal/coordinator/confirm_gate_test.go @@ -0,0 +1,91 @@ +package coordinator + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" +) + +func TestConfirmGate_WeakVerbNeedsConfirmation(t *testing.T) { + eng := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + User: UserInfo{ID: "1", Name: "u"}, + Project: ProjectInfo{ID: "9", Name: "P", CurrentStage: maturity.StageV01_Triage, ActiveWorkflowKey: "main"}, + } + cand := &intent.CandidateIntent{ + Intent: intent.IntentTriggerTask, + Confidence: 0.78, + Branch: intent.BranchNewIntentPreempt, + FilledSlots: map[string]string{ + intent.SlotTargetTask: "intake", + intent.SlotNeedsConfirmation: "true", + }, + } + res, err := eng.handleTriggerTask(context.Background(), cand, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusClarificationRequired { + t.Fatalf("status=%s want CLARIFICATION_REQUIRED", res.Status) + } + if res.NextExpectation == nil || len(res.NextExpectation.Options) < 2 { + t.Fatalf("missing confirm expectation: %+v", res.NextExpectation) + } + if res.NextExpectation.Options[0].Slots[intent.SlotTargetTask] != "intake" { + t.Fatalf("default option task=%v", res.NextExpectation.Options[0].Slots) + } + if !strings.Contains(res.NaturalReply, "用户初始需求收集表") { + t.Fatalf("reply missing intake title: %s", res.NaturalReply) + } + if userCtx.PendingExpectation == nil { + t.Fatal("pending expectation must be applied") + } +} + +func TestConfirmGate_StrongHighConfidenceDirect(t *testing.T) { + eng := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + User: UserInfo{ID: "1", Name: "u"}, + Project: ProjectInfo{ID: "9", Name: "P", CurrentStage: maturity.StageV01_Triage, ActiveWorkflowKey: "main"}, + } + cand := &intent.CandidateIntent{ + Intent: intent.IntentTriggerTask, + Confidence: 0.86, + Branch: intent.BranchNewIntentPreempt, + FilledSlots: map[string]string{ + intent.SlotTargetTask: "intake", + }, + } + res, err := eng.handleTriggerTask(context.Background(), cand, userCtx) + if err != nil { + t.Fatal(err) + } + // nil tasks runner → accepted stub reply, not clarification + if res.Status != StatusAccepted { + t.Fatalf("status=%s want ACCEPTED (no confirm for strong high-conf)", res.Status) + } + if strings.Contains(res.NaturalReply, "请确认") { + t.Fatalf("must not ask confirm: %s", res.NaturalReply) + } +} + +func TestConfirmGate_StageMismatchRemapsDefaultToIntake(t *testing.T) { + // Force stage-mismatch path without instSvc by calling confirmGate helper with a + // synthetic candidate; exercise buildTriggerConfirmExpectation via public path + // using mid-confidence + manual expectation builder check. + exp := buildTriggerConfirmExpectation("user_requirements", "用户需求表", &UserProjectContext{ + Project: ProjectInfo{CurrentStage: maturity.StageV01_Triage}, + }, true) + if exp.Options[0].Slots[intent.SlotTargetTask] != "intake" { + t.Fatalf("stage mismatch default must remap to intake, got %v", exp.Options[0].Slots) + } + if len(exp.Options) < 3 { + t.Fatalf("want advance-stage option, got %d options", len(exp.Options)) + } + if exp.Options[2].Intent != intent.IntentAdvanceStage { + t.Fatalf("option 3 want ADVANCE_STAGE, got %s", exp.Options[2].Intent) + } +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index ab466d78..6524b319 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -972,6 +972,11 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. } } + // 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。 + if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil { + return gated, nil + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp)