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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 08:27:49 +00:00
co-authored by Cursor
parent f8abd1a5cc
commit 5ba9bbbe11
3 changed files with 299 additions and 0 deletions
+203
View File
@@ -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()
}
+91
View File
@@ -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)
}
}
+5
View File
@@ -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)