fix(coordinator): chain expectation for card confirmation and add ttl lifecycle cleanup

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 18:22:13 +08:00
co-authored by Cursor
parent 73b50926e4
commit 6e4fe22bfb
5 changed files with 643 additions and 17 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ func (ctx *UserProjectContext) ToSnapshot() *intent.ContextSnapshot {
} else if ctx.User.Role != "" {
snap.UserDocRole = ctx.User.Role
}
if ctx.InteractiveCard != nil && ctx.InteractiveCard.TargetDocID != "" {
if ctx.InteractiveCard != nil && ctx.InteractiveCard.CurrentStatus == "active" {
snap.ActiveDocID = ctx.InteractiveCard.TargetDocID
}
return snap
+247 -7
View File
@@ -68,6 +68,18 @@ func (e *CoordinatorEngine) ProcessIntent(
}
}
// 交互卡片 TTL 轮次消退与生命周期清理
if userCtx.InteractiveCard != nil {
userCtx.InteractiveCard.TTLTurns--
if userCtx.InteractiveCard.TTLTurns <= 0 || userCtx.InteractiveCard.CurrentStatus != "active" {
userCtx.InteractiveCard.CurrentStatus = "expired"
userCtx.InteractiveCard = nil
}
}
if userCtx.PendingExpectation != nil && !userCtx.PendingExpectation.ExpiresAt.IsZero() && time.Now().After(userCtx.PendingExpectation.ExpiresAt) {
userCtx.PendingExpectation = nil
}
top := recRes.TopIntent
if top == nil {
top = &intent.CandidateIntent{
@@ -225,6 +237,10 @@ func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext
projName = "当前项目"
}
userCtx.PendingExpectation = nil
if userCtx.InteractiveCard != nil {
userCtx.InteractiveCard.CurrentStatus = "cancelled"
userCtx.InteractiveCard = nil
}
reply := fmt.Sprintf("收到,听您的!那我们先不急着推进。\n针对「%s」,您手头还有哪些细节、技术顾虑或商务考量想先聊聊?我随时待命为您解答。", projName)
@@ -742,6 +758,7 @@ func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent
"target_doc": docName,
"reject_reason": proposedComment,
"section": section,
"target_action": "confirm_reject",
},
},
{
@@ -757,6 +774,26 @@ func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent
userCtx.InteractiveCard = cardPayload
nextExp := &intent.PendingExpectation{
ExpectationID: "exp_" + cardPayload.CardID,
ExpectedIntent: intent.IntentDocGovProposalDecide,
TargetAction: "confirm_reject",
Prompt: "请确认是否执行驳回操作",
AffirmativeWord: "确认",
DeclineWord: "取消",
ExpiresAt: time.Now().Add(5 * time.Minute),
Payload: cardPayload.Actions[0].Payload,
AllowAffirmative: true,
CreatedAt: time.Now(),
DefaultOption: &intent.ExpectationOption{
OptionID: "confirm_reject",
Title: "确认正式驳回退回",
Intent: intent.IntentDocGovProposalDecide,
Slots: cardPayload.Actions[0].Payload,
},
}
userCtx.PendingExpectation = nextExp
naturalReply := fmt.Sprintf("已为您起草针对《%s》的驳回流转意见。请审阅下方卡片中的拟注入批注,确认无误后点击确认或回复‘确认’。", docName)
return &CoordinationResult{
@@ -765,6 +802,7 @@ func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent
Branch: cand.Branch,
NaturalReply: naturalReply,
InteractiveCard: cardPayload,
NextExpectation: nextExp,
UpdatedContext: userCtx,
}, nil
}
@@ -791,21 +829,144 @@ func extractSectionClues(text string) string {
return ""
}
// handleDocGovApprove 处理受控文档审核/批准通过
// isReviewerAuthorized 校验审核人或主导工程师权限
func isReviewerAuthorized(u UserInfo) bool {
r := strings.ToLower(strings.TrimSpace(u.Role))
pr := strings.ToLower(strings.TrimSpace(u.ProjectRole))
return r == "reviewer" || pr == "reviewer" ||
r == "lead_engineer" || pr == "lead_engineer" ||
strings.Contains(pr, "审核") || strings.Contains(r, "审核") ||
strings.Contains(pr, "主导") || strings.Contains(r, "主导")
}
// isApproverAuthorized 校验批准人或项目经理权限
func isApproverAuthorized(u UserInfo) bool {
r := strings.ToLower(strings.TrimSpace(u.Role))
pr := strings.ToLower(strings.TrimSpace(u.ProjectRole))
return r == "approver" || pr == "approver" ||
r == "project_manager" || pr == "project_manager" ||
strings.Contains(pr, "批准") || strings.Contains(r, "批准") ||
strings.Contains(pr, "总监") || strings.Contains(r, "总监") ||
r == "director"
}
// handleDocGovApprove 处理受控文档审核/批准通过与终审批准二次确认
func (e *CoordinatorEngine) handleDocGovApprove(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
docName = "当前受控文档"
if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
} else {
docName = "当前受控文档"
}
}
currentRole := userCtx.User.ProjectRole
if currentRole == "" {
currentRole = userCtx.User.Role
}
if currentRole == "" {
currentRole = "未知角色"
}
isFinal := cand.Intent == intent.IntentDocGovFinalApprove
var reply string
if isFinal {
reply = fmt.Sprintf("已收到您对《%s》的终审批准指令。文档已被法定受控锁定并正式归档生效,后续将自动沉淀为高置信度往期标杆案例。", docName)
} else {
reply = fmt.Sprintf("已收到您对《%s》的审核通过评审。文档已流转至批准环节,等待总监进行终审批准。", docName)
// 校验终审批准权限: approver 或 project_manager
if !isApproverAuthorized(userCtx.User) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_APPROVAL_ROLE",
NaturalReply: fmt.Sprintf("权限不足:受控文档终审批准需要批准人 (approver) 或项目经理 (project_manager) 权限。当前您的身份为「%s」,无权执行终审批准操作。", currentRole),
UpdatedContext: userCtx,
}, nil
}
cardID := fmt.Sprintf("card_final_appr_%d", time.Now().UnixNano())
cardPayload := &intent.InteractiveCardPayload{
CardID: cardID,
CardType: "CONFIRMATION",
Title: "受控文档终审批准生效确认",
TargetDocID: docName,
TargetDocTitle: fmt.Sprintf("《%s》", docName),
ProposedContent: "正式终审批准受控文档,签署电子签名并受控归档生效",
ImpactRisk: "终审批准后文档正文将永久锁定为基线版本,后续非受控修改将被严格拦截,任何变更必须走变更审批流程",
CurrentStatus: "active",
TTLTurns: 3,
CreatedAt: time.Now(),
Actions: []intent.CardAction{
{
ActionID: "confirm_final_approve",
Label: "确认终审批准生效",
Style: "primary",
IsAffirmativeDefault: true,
Payload: map[string]string{
"target_doc": docName,
"target_action": "confirm_final_approve",
},
},
{
ActionID: "cancel",
Label: "取消",
Style: "subtle",
Payload: map[string]string{
"action": "cancel",
},
},
},
}
nextExp := &intent.PendingExpectation{
ExpectationID: "exp_" + cardPayload.CardID,
ExpectedIntent: intent.IntentDocGovProposalDecide,
TargetAction: "confirm_final_approve",
Prompt: "请确认是否执行终审批准操作",
AffirmativeWord: "确认",
DeclineWord: "取消",
ExpiresAt: time.Now().Add(5 * time.Minute),
Payload: cardPayload.Actions[0].Payload,
AllowAffirmative: true,
CreatedAt: time.Now(),
DefaultOption: &intent.ExpectationOption{
OptionID: "confirm_final_approve",
Title: "确认终审批准生效",
Intent: intent.IntentDocGovProposalDecide,
Slots: cardPayload.Actions[0].Payload,
},
}
userCtx.InteractiveCard = cardPayload
userCtx.PendingExpectation = nextExp
reply := fmt.Sprintf("已为您起草针对《%s》的终审批准确认单。终审批准将使文档正式归档并永久锁定,请审阅下方卡片并在确认无误后点击确认或回复‘确认’。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
InteractiveCard: cardPayload,
NextExpectation: nextExp,
UpdatedContext: userCtx,
}, nil
}
// 校验审核通过权限: reviewer 或 lead_engineer
if !isReviewerAuthorized(userCtx.User) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REVIEW_ROLE",
NaturalReply: fmt.Sprintf("权限不足:受控文档审核通过需要审核人 (reviewer) 或主导工程师 (lead_engineer) 权限。当前您的身份为「%s」,无权执行审核通过操作。", currentRole),
UpdatedContext: userCtx,
}, nil
}
reply := fmt.Sprintf("已收到您对《%s》的审核通过评审。文档已流转至批准环节,等待批准人/项目经理进行终审批准。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
@@ -863,8 +1024,87 @@ func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *
}, nil
}
// handleDocGovProposalDecide 处理智能体修改建议与提案裁决
// handleDocGovProposalDecide 处理智能体修改建议与提案裁决,以及驳回/终审批准确认执行
func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
targetAction := cand.FilledSlots["target_action"]
if targetAction == "" {
targetAction = cand.FilledSlots["action"]
}
if targetAction == "" && cand.MatchedOption != nil {
targetAction = cand.MatchedOption.OptionID
}
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" && userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
}
if docName == "" {
docName = "当前受控文档"
}
if targetAction == "confirm_reject" {
reason := cand.FilledSlots[intent.SlotRejectReason]
if reason == "" && userCtx.InteractiveCard != nil {
reason = userCtx.InteractiveCard.ProposedContent
}
if userCtx.InteractiveCard != nil {
userCtx.InteractiveCard.CurrentStatus = "resolved"
userCtx.InteractiveCard = nil
}
userCtx.PendingExpectation = nil
reply := fmt.Sprintf("已确认执行驳回操作!《%s》已正式退回至编制中(drafting)状态,驳回批注意见(%s)已同步下发并通知编制责任人重新修订。", docName, reason)
if reason == "" {
reply = fmt.Sprintf("已确认执行驳回操作!《%s》已正式退回至编制中(drafting)状态,已通知编制责任人重新修订。", docName)
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
ClearExpectation: true,
}, nil
}
if targetAction == "confirm_final_approve" {
if userCtx.InteractiveCard != nil {
userCtx.InteractiveCard.CurrentStatus = "resolved"
userCtx.InteractiveCard = nil
}
userCtx.PendingExpectation = nil
reply := fmt.Sprintf("已确认终审批准!《%s》已被法定受控锁定并正式归档生效,后续将自动沉淀为高置信度往期标杆案例。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
ClearExpectation: true,
}, nil
}
if targetAction == "cancel" {
if userCtx.InteractiveCard != nil {
userCtx.InteractiveCard.CurrentStatus = "cancelled"
userCtx.InteractiveCard = nil
}
userCtx.PendingExpectation = nil
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已取消针对《%s》的操作,文档状态保持不变。", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
}, nil
}
action := cand.FilledSlots[intent.SlotProposalAction]
var reply string
if action == "reject" {
+348
View File
@@ -264,6 +264,15 @@ func TestCoordinatorEngine_ReviewRejectWithCard(t *testing.T) {
if !strings.Contains(res.NaturalReply, "驳回") {
t.Errorf("expected natural reply to mention 驳回, got %s", res.NaturalReply)
}
if res.NextExpectation == nil {
t.Fatalf("expected NextExpectation in reject result")
}
if res.NextExpectation.TargetAction != "confirm_reject" {
t.Errorf("expected target action confirm_reject, got %s", res.NextExpectation.TargetAction)
}
if res.NextExpectation.ExpectedIntent != intent.IntentDocGovProposalDecide {
t.Errorf("expected expected intent DOCGOV_PROPOSAL_DECIDE, got %s", res.NextExpectation.ExpectedIntent)
}
}
func TestCoordinatorEngine_SecurityForbidden(t *testing.T) {
@@ -480,3 +489,342 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
}
}
// TestCoordinatorEngine_RejectExpectationAndAffirmativeChain 测试驳回二次确认链条 (挂载 NextExpectation 与多词确认闭环)
func TestCoordinatorEngine_RejectExpectationAndAffirmativeChain(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
rec := intent.NewRecognizer()
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{
ID: "user_102",
Name: "李审核",
Role: "reviewer",
ProjectRole: "reviewer",
},
Project: coordinator.ProjectInfo{
Name: "心血管介入导管项目",
},
}
// 1. 发起驳回,产生交互卡片与 NextExpectation
recReject := &intent.IntentRecognitionResult{
Branch: intent.BranchNewIntentPreempt,
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovReviewReject,
FilledSlots: map[string]string{
intent.SlotTargetDoc: "doc_cardio_01",
intent.SlotRejectReason: "第3节缺少血液相容性溶血试验评价指标",
intent.SlotTargetSection: "第3节",
},
},
}
resReject, err := engine.ProcessIntent(context.Background(), recReject, userCtx)
if err != nil {
t.Fatalf("process reject failed: %v", err)
}
if resReject.Status != coordinator.StatusAccepted {
t.Fatalf("expected reject status ACCEPTED, got %s", resReject.Status)
}
if resReject.InteractiveCard == nil {
t.Fatalf("expected InteractiveCard to be generated")
}
if resReject.InteractiveCard.CardType != "CONFIRMATION" {
t.Errorf("expected card type CONFIRMATION, got %s", resReject.InteractiveCard.CardType)
}
if resReject.InteractiveCard.TTLTurns != 3 {
t.Errorf("expected TTLTurns 3, got %d", resReject.InteractiveCard.TTLTurns)
}
// 验证 NextExpectation 挂载正确
if resReject.NextExpectation == nil {
t.Fatalf("expected NextExpectation to be populated")
}
if resReject.NextExpectation.TargetAction != "confirm_reject" {
t.Errorf("expected TargetAction confirm_reject, got %s", resReject.NextExpectation.TargetAction)
}
if resReject.NextExpectation.ExpectedIntent != intent.IntentDocGovProposalDecide {
t.Errorf("expected ExpectedIntent DOCGOV_PROPOSAL_DECIDE, got %s", resReject.NextExpectation.ExpectedIntent)
}
if resReject.NextExpectation.AffirmativeWord != "确认" {
t.Errorf("expected AffirmativeWord 确认, got %s", resReject.NextExpectation.AffirmativeWord)
}
if resReject.NextExpectation.DeclineWord != "取消" {
t.Errorf("expected DeclineWord 取消, got %s", resReject.NextExpectation.DeclineWord)
}
// 2. 验证用户说 "确认", "好", "同意" 均能命中 Branch 1 并正确驱动协调层执行驳回
affirmativeWords := []string{"确认", "好", "同意"}
for _, word := range affirmativeWords {
// 每次使用相同初始化的上下文进行分支 1 验证
testCtx := &coordinator.UserProjectContext{
User: userCtx.User,
Project: userCtx.Project,
InteractiveCard: resReject.InteractiveCard,
PendingExpectation: resReject.NextExpectation,
}
snap := testCtx.ToSnapshot()
recRes, err := rec.Recognize(context.Background(), word, snap)
if err != nil {
t.Fatalf("recognize '%s' failed: %v", word, err)
}
if recRes.Branch != intent.BranchExpectationHit {
t.Errorf("word '%s': expected branch EXPECTATION_HIT, got %s", word, recRes.Branch)
}
if recRes.TopIntent.Intent != intent.IntentDocGovProposalDecide {
t.Errorf("word '%s': expected intent DOCGOV_PROPOSAL_DECIDE, got %s", word, recRes.TopIntent.Intent)
}
// 协调层处理确认
coordRes, err := engine.ProcessIntent(context.Background(), recRes, testCtx)
if err != nil {
t.Fatalf("coord process confirmation failed for word '%s': %v", word, err)
}
if coordRes.Status != coordinator.StatusAccepted {
t.Errorf("word '%s': expected status ACCEPTED, got %s", word, coordRes.Status)
}
if !coordRes.ClearExpectation {
t.Errorf("word '%s': expected ClearExpectation true", word)
}
if !strings.Contains(coordRes.NaturalReply, "已确认执行驳回操作") {
t.Errorf("word '%s': expected natural reply to confirm reject execution, got: %s", word, coordRes.NaturalReply)
}
if testCtx.InteractiveCard != nil {
t.Errorf("word '%s': expected InteractiveCard to be cleared after resolution", word)
}
}
}
// TestCoordinatorEngine_TTLTurnsDecayAndCardCleanup 测试卡片生命周期消退与过期清理
func TestCoordinatorEngine_TTLTurnsDecayAndCardCleanup(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{
ID: "user_101",
Name: "张工",
Role: "engineer",
ProjectRole: "composer",
},
Project: coordinator.ProjectInfo{
Name: "测试项目",
},
InteractiveCard: &intent.InteractiveCardPayload{
CardID: "card_test_ttl",
CardType: "CONFIRMATION",
TargetDocID: "doc_test_001",
CurrentStatus: "active",
TTLTurns: 2,
},
}
// 验证当前活动状态下 ToSnapshot() 正确输出 ActiveDocID
snap0 := userCtx.ToSnapshot()
if snap0.ActiveDocID != "doc_test_001" {
t.Fatalf("expected ActiveDocID doc_test_001, got %s", snap0.ActiveDocID)
}
// 轮次 1:执行一个无关对话意图,TTLTurns 衰减为 1
rec1 := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentCasualChat,
},
}
_, err := engine.ProcessIntent(context.Background(), rec1, userCtx)
if err != nil {
t.Fatalf("turn 1 failed: %v", err)
}
if userCtx.InteractiveCard == nil {
t.Fatalf("turn 1: expected card to still exist with TTL 1")
}
if userCtx.InteractiveCard.TTLTurns != 1 {
t.Errorf("turn 1: expected TTLTurns 1, got %d", userCtx.InteractiveCard.TTLTurns)
}
snap1 := userCtx.ToSnapshot()
if snap1.ActiveDocID != "doc_test_001" {
t.Errorf("turn 1: expected ActiveDocID doc_test_001, got %s", snap1.ActiveDocID)
}
// 轮次 2:再执行一个无关意图,TTLTurns 衰减为 0 <= 0,卡片过期并置空清理
rec2 := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentCasualChat,
},
}
_, err = engine.ProcessIntent(context.Background(), rec2, userCtx)
if err != nil {
t.Fatalf("turn 2 failed: %v", err)
}
if userCtx.InteractiveCard != nil {
t.Errorf("turn 2: expected card to be cleared after TTL expiration, got %+v", userCtx.InteractiveCard)
}
// 快照不再包含失效的 ActiveDocID
snap2 := userCtx.ToSnapshot()
if snap2.ActiveDocID != "" {
t.Errorf("turn 2: expected ActiveDocID to be empty after expiry, got %s", snap2.ActiveDocID)
}
// 验证非 active 状态卡片立即被清理
userCtx.InteractiveCard = &intent.InteractiveCardPayload{
CardID: "card_cancelled",
CardType: "CONFIRMATION",
TargetDocID: "doc_test_002",
CurrentStatus: "cancelled",
TTLTurns: 5,
}
// ToSnapshot() 对非 active 状态忽略 ActiveDocID
if userCtx.ToSnapshot().ActiveDocID != "" {
t.Errorf("expected non-active card to not set ActiveDocID in snapshot")
}
// 经过 ProcessIntent 自动清理置空
_, _ = engine.ProcessIntent(context.Background(), rec1, userCtx)
if userCtx.InteractiveCard != nil {
t.Errorf("expected non-active card to be cleared by ProcessIntent")
}
}
// TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard 测试批准动作角色权责核验与终审批准二次确认
func TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
rec := intent.NewRecognizer()
// 1. 审核通过 (ReviewApprove):未授权角色 (composer / engineer) 必须被拒绝
composerCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{
ID: "user_composer",
Name: "张编制",
Role: "engineer",
ProjectRole: "composer",
},
Project: coordinator.ProjectInfo{
Name: "血液透析机项目",
},
}
recReviewApprove := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovReviewApprove,
FilledSlots: map[string]string{
intent.SlotTargetDoc: "doc_dialysis_01",
},
},
}
resUnauthorizedReview, err := engine.ProcessIntent(context.Background(), recReviewApprove, composerCtx)
if err != nil {
t.Fatalf("process review approve failed: %v", err)
}
if resUnauthorizedReview.Status != coordinator.StatusRejected {
t.Errorf("expected review approve by composer to be REJECTED, got %s", resUnauthorizedReview.Status)
}
if resUnauthorizedReview.RejectReason != "UNAUTHORIZED_REVIEW_ROLE" {
t.Errorf("expected reject reason UNAUTHORIZED_REVIEW_ROLE, got %s", resUnauthorizedReview.RejectReason)
}
if !strings.Contains(resUnauthorizedReview.NaturalReply, "权限不足") {
t.Errorf("expected natural reply to explain permission lack, got %s", resUnauthorizedReview.NaturalReply)
}
// 2. 审核通过 (ReviewApprove):合法角色 (lead_engineer 或 reviewer) 允许通过
reviewerCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{
ID: "user_lead",
Name: "赵主管",
Role: "lead_engineer",
ProjectRole: "reviewer",
},
Project: coordinator.ProjectInfo{
Name: "血液透析机项目",
},
}
resAuthorizedReview, err := engine.ProcessIntent(context.Background(), recReviewApprove, reviewerCtx)
if err != nil {
t.Fatalf("process review approve with lead_engineer failed: %v", err)
}
if resAuthorizedReview.Status != coordinator.StatusAccepted {
t.Errorf("expected review approve by lead_engineer to be ACCEPTED, got %s", resAuthorizedReview.Status)
}
// 3. 终审批准 (FinalApprove):未授权角色 (reviewer) 必须被拒绝
recFinalApprove := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovFinalApprove,
FilledSlots: map[string]string{
intent.SlotTargetDoc: "doc_dialysis_01",
},
},
}
resUnauthorizedFinal, err := engine.ProcessIntent(context.Background(), recFinalApprove, reviewerCtx)
if err != nil {
t.Fatalf("process final approve with reviewer failed: %v", err)
}
if resUnauthorizedFinal.Status != coordinator.StatusRejected {
t.Errorf("expected final approve by reviewer to be REJECTED, got %s", resUnauthorizedFinal.Status)
}
if resUnauthorizedFinal.RejectReason != "UNAUTHORIZED_APPROVAL_ROLE" {
t.Errorf("expected reject reason UNAUTHORIZED_APPROVAL_ROLE, got %s", resUnauthorizedFinal.RejectReason)
}
// 4. 终审批准 (FinalApprove):合法角色 (approver 或 project_manager) 生成 CONFIRMATION 卡片与 NextExpectation
approverCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{
ID: "user_pm",
Name: "王总监",
Role: "project_manager",
ProjectRole: "approver",
},
Project: coordinator.ProjectInfo{
Name: "血液透析机项目",
},
}
resAuthorizedFinal, err := engine.ProcessIntent(context.Background(), recFinalApprove, approverCtx)
if err != nil {
t.Fatalf("process final approve with approver failed: %v", err)
}
if resAuthorizedFinal.Status != coordinator.StatusAccepted {
t.Fatalf("expected final approve by approver to be ACCEPTED, got %s", resAuthorizedFinal.Status)
}
if resAuthorizedFinal.InteractiveCard == nil {
t.Fatalf("expected CONFIRMATION card for final approve")
}
if resAuthorizedFinal.InteractiveCard.CardType != "CONFIRMATION" {
t.Errorf("expected card type CONFIRMATION, got %s", resAuthorizedFinal.InteractiveCard.CardType)
}
if resAuthorizedFinal.InteractiveCard.TTLTurns != 3 {
t.Errorf("expected card TTLTurns 3, got %d", resAuthorizedFinal.InteractiveCard.TTLTurns)
}
if resAuthorizedFinal.NextExpectation == nil {
t.Fatalf("expected NextExpectation for final approve confirmation")
}
if resAuthorizedFinal.NextExpectation.TargetAction != "confirm_final_approve" {
t.Errorf("expected target action confirm_final_approve, got %s", resAuthorizedFinal.NextExpectation.TargetAction)
}
// 5. 模拟总监在下一轮回复“确认”,分支 1 命中并正式受控锁定归档
snap := approverCtx.ToSnapshot()
recConfirm, err := rec.Recognize(context.Background(), "确认", snap)
if err != nil {
t.Fatalf("recognize confirm failed: %v", err)
}
if recConfirm.Branch != intent.BranchExpectationHit {
t.Errorf("expected branch EXPECTATION_HIT, got %s", recConfirm.Branch)
}
resConfirm, err := engine.ProcessIntent(context.Background(), recConfirm, approverCtx)
if err != nil {
t.Fatalf("process confirm final approve failed: %v", err)
}
if resConfirm.Status != coordinator.StatusAccepted {
t.Errorf("expected confirm status ACCEPTED, got %s", resConfirm.Status)
}
if !strings.Contains(resConfirm.NaturalReply, "已确认终审批准") {
t.Errorf("expected natural reply to confirm final approval, got: %s", resConfirm.NaturalReply)
}
if approverCtx.InteractiveCard != nil {
t.Errorf("expected interactive card to be cleared after final approve confirmation")
}
if approverCtx.PendingExpectation != nil {
t.Errorf("expected PendingExpectation to be cleared after final approve confirmation")
}
}
+34 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"strings"
"time"
"chat-agent-system/internal/bizdev"
)
@@ -621,8 +622,16 @@ func extractSection(input string) string {
// matchExpectationHit 检验是否命中挂起预期 (分支 1)
func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent {
// 0. 检验超时过期
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
return nil
}
// 1. 否定/暂缓意图
declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等"}
declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等", "取消", "放弃"}
if pending.DeclineWord != "" {
declineKeywords = append(declineKeywords, pending.DeclineWord)
}
for _, dk := range declineKeywords {
if input == dk || strings.HasPrefix(input, dk) {
return &CandidateIntent{
@@ -643,12 +652,23 @@ func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIn
matchedOpt = &pending.Options[optIndex]
}
// 3. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办")
affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定"}
// 3. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办", "确认")
affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定", "确认", "确定", "批准"}
if pending.AffirmativeWord != "" {
affirmativePrefixes = append(affirmativePrefixes, pending.AffirmativeWord)
}
isAffirmative := false
for _, aff := range affirmativePrefixes {
if input == aff || strings.HasPrefix(input, aff+",") || strings.HasPrefix(input, aff+",") || strings.HasPrefix(input, aff+"但") || strings.HasPrefix(input, aff+" ") {
if input == aff ||
strings.HasPrefix(input, aff+",") ||
strings.HasPrefix(input, aff+",") ||
strings.HasPrefix(input, aff+"但") ||
strings.HasPrefix(input, aff+" ") ||
strings.HasPrefix(input, aff+"!") ||
strings.HasPrefix(input, aff+"!") ||
(aff == "确认" && (input == "确认" || input == "确认驳回" || input == "确认批准" || input == "确认执行")) ||
(pending.AffirmativeWord != "" && strings.HasPrefix(input, pending.AffirmativeWord)) {
isAffirmative = true
break
}
@@ -659,6 +679,13 @@ func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIn
matchedOpt = pending.DefaultOption
} else if len(pending.Options) > 0 {
matchedOpt = &pending.Options[0]
} else if pending.ExpectedIntent != "" {
matchedOpt = &ExpectationOption{
OptionID: pending.TargetAction,
Title: pending.Prompt,
Intent: pending.ExpectedIntent,
Slots: pending.Payload,
}
}
}
@@ -677,6 +704,9 @@ func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIn
for k, v := range patchSlots {
filledSlots[k] = v
}
if pending.TargetAction != "" && filledSlots["target_action"] == "" {
filledSlots["target_action"] = pending.TargetAction
}
confidence := 1.0
reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title)
+13 -5
View File
@@ -53,11 +53,19 @@ type ExpectationOption struct {
// PendingExpectation 挂起预期草稿(非阻塞桌角备忘贴纸)
type PendingExpectation struct {
ContextSnippet string `json:"context_snippet"` // 上一轮建议的业务背景简述
DefaultOption *ExpectationOption `json:"default_option"` // 参谋最推荐的基准选项(用户说“好/行”时默认执行)
Options []ExpectationOption `json:"options"` // 供用户挑选的备选选项列表
AllowAffirmative bool `json:"allow_affirmative"` // 是否允许用单字肯定(好/行/可以)触发
CreatedAt time.Time `json:"created_at"` // 挂起时间
ExpectationID string `json:"expectation_id,omitempty"`
ExpectedIntent IntentType `json:"expected_intent,omitempty"`
TargetAction string `json:"target_action,omitempty"`
Prompt string `json:"prompt,omitempty"`
AffirmativeWord string `json:"affirmative_word,omitempty"`
DeclineWord string `json:"decline_word,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Payload map[string]string `json:"payload,omitempty"`
ContextSnippet string `json:"context_snippet,omitempty"` // 上一轮建议的业务背景简述
DefaultOption *ExpectationOption `json:"default_option,omitempty"` // 参谋最推荐的基准选项(用户说“好/行”时默认执行)
Options []ExpectationOption `json:"options,omitempty"` // 供用户挑选的备选选项列表
AllowAffirmative bool `json:"allow_affirmative"` // 是否允许用单字肯定(好/行/可以)触发
CreatedAt time.Time `json:"created_at"` // 挂起时间
}
// CandidateIntent 候选意图提案(L1 对话层输出,由 L2 协调层终审)