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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user