fix(coordinator): chain expectation for card confirmation and add ttl lifecycle cleanup
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 协调层终审)
|
||||
|
||||
Reference in New Issue
Block a user