diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 7bafb6d6..a7544f0d 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -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 diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 27f11602..38bef041 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -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" { diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 1247cbcb..da07f26d 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -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") + } +} + diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 1ee82135..44e991e7 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -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) diff --git a/internal/intent/types.go b/internal/intent/types.go index 41896ef9..4fa26e19 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -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 协调层终审)