Files
weichen d55488ae37 feat(coordinator): 用智能体叙述查询、帮助与操作回执
确定性事实仍由代码生成;PI Agent 只组织语言,失败或关闭开关时回退原模板。安全拦截与确认提问保持模板。
2026-09-28 03:25:06 +00:00

591 lines
22 KiB
Go

package coordinator
import (
"chat-agent-system/internal/contracts/vocabulary"
"context"
"fmt"
"strconv"
"strings"
"time"
"chat-agent-system/internal/intent"
)
// handleDocGovReject 处理受控文档审核/批准驳回与二次确认交互卡片挂载(角色硬门禁)
func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
} else {
docName = "当前受控文档"
}
}
rawReason := cand.FilledSlots[intent.SlotRejectReason]
section := cand.FilledSlots[intent.SlotTargetSection]
if section == "" {
section = extractSectionClues(rawReason)
}
role := effectiveDocRole(userCtx)
isFinal := cand.Intent == intent.IntentDocGovFinalReject
// 与批准对称的硬门禁
if isFinal {
if !isApproverAuthorized(userCtx.User) && role != "approver" {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REJECT_ROLE",
NaturalReply: fmt.Sprintf("权限不足:终审驳回需批准人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问。", roleLabelCN(role)),
UpdatedContext: userCtx,
}, nil
}
} else {
if !isReviewerAuthorized(userCtx.User) && role != "reviewer" {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REJECT_ROLE",
NaturalReply: fmt.Sprintf("权限不足:审核驳回需审核人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问「被驳回的原因是什么」。", roleLabelCN(role)),
UpdatedContext: userCtx,
}, nil
}
}
// 规范化策略角色(授权通过后按意图选审核/批准确认卡)
policyRole := role
if isFinal {
policyRole = "approver"
} else if policyRole != "reviewer" && isReviewerAuthorized(userCtx.User) {
policyRole = "reviewer"
}
lifecycle := "reviewing"
if isFinal {
lifecycle = "approving"
}
resolved := ResolveRejectCard(policyRole, lifecycle, docName, docName, section, rawReason, RejectUtteranceExecute)
if resolved.Kind == "deny_text" || resolved.Card == nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: resolved.DenyReason,
NaturalReply: resolved.Reply,
UpdatedContext: userCtx,
}, nil
}
userCtx.applyCard(resolved.Card)
userCtx.applyPending(resolved.Expectation)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: resolved.Reply,
InteractiveCard: resolved.Card,
NextExpectation: resolved.Expectation,
UpdatedContext: userCtx,
}, nil
}
// 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 == "" {
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
if isFinal {
// 校验终审批准权限: 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.applyCard(cardPayload)
userCtx.applyPending(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 e.applyNarrative(ctx, userCtx, "handleDocGovApprove", "", "这是审核通过请求的受理通报。必须保留文档名,并说明正在处理,不得宣称已经批准归档。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
PendingAction: &PendingAction{
Kind: "docgov.review_approve",
DocID: docName,
Comment: cand.FilledSlots[intent.SlotRejectReason],
},
}), nil
}
// handleDocGovClaim 解析门 → 确认卡 → 拍板后 PendingAction(docgov.claim)
func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil {
return gate, nil
}
title := displayDocTitle(doc)
card, exp := buildConfirmCard(
"受控文档认领确认",
doc.DocID, title, "",
"确认认领该文档并开始编制责任",
"认领将记录您已查看并承接该文档的编制责任;不会静默选择其他文档。",
"confirm_claim", "确认认领",
map[string]string{"task_key": doc.TaskKey},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
reply := fmt.Sprintf("已为《%s》起草认领确认单。请确认文档无误后点击确认或回复「确认」。", title)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
InteractiveCard: card,
NextExpectation: exp,
UpdatedContext: userCtx,
}, nil
}
// handleDocGovSubmitReview 解析门 → drafting 校验 → CONFIRMATION 卡(含锁定后果)→ 拍板后 PendingAction
func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil {
return gate, nil
}
state := doc.State
// ContextSnapshot hardcodes DocLifecycleState="reviewing" whenever an interactive
// card has ActiveDocID — do not treat that as authoritative unless the resolver
// already supplied a real lifecycle state.
if state != "" && state != vocabulary.StateDrafting {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "SUBMIT_STATE_DENIED",
NaturalReply: fmt.Sprintf("当前文档处于「%s」阶段,仅编制中(drafting)可送审。", state),
UpdatedContext: userCtx,
}, nil
}
title := displayDocTitle(doc)
card, exp := buildConfirmCard(
"受控文档送审确认",
doc.DocID, title, "",
"编制完成,提交审核并锁定正文",
"送审后文档进入审核锁定:非审核职责人不可改正文;锁定期间补充将进入积压台账,待退回编制后再消化。",
"confirm_submit_review", "确认送审",
map[string]string{"task_key": doc.TaskKey},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
reply := fmt.Sprintf("已为《%s》起草送审确认单。送审将锁定正文并通知审核人,请确认无误后点击确认或回复「确认」。", title)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
InteractiveCard: card,
NextExpectation: exp,
UpdatedContext: userCtx,
}, nil
}
// 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.dismissCard("resolved")
}
userCtx.applyPending(nil)
rollback := vocabulary.StateDrafting
if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") {
rollback = vocabulary.StateDrafting
}
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理您对《%s》的驳回确认…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reject",
DocID: docName,
Reason: reason,
Rollback: rollback,
},
}), nil
}
if targetAction == "confirm_final_approve" {
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理您对《%s》的终审批准…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.approver_approve",
DocID: docName,
},
}), nil
}
if targetAction == "confirm_claim" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理《%s》的认领…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.claim",
DocID: docName,
TaskKey: payload["task_key"],
},
}), nil
}
if targetAction == "confirm_submit_review" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
taskKey := payload["task_key"]
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理《%s》的送审…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{Kind: "docgov.submit_review", DocID: docName, TaskKey: taskKey},
}), nil
}
if targetAction == "confirm_workflow_run" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
wfKey := payload[intent.SlotTargetWorkflow]
if wfKey == "" {
wfKey = payload["workflow_key"]
}
if wfKey == "" && userCtx != nil {
wfKey = userCtx.Project.ActiveWorkflowKey
}
if wfKey == "" {
wfKey = "main"
}
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在启动工作流「%s」…", workflowDisplayName(wfKey)),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "workflow.run",
WorkflowKey: wfKey,
},
}), nil
}
if targetAction == "confirm_comment_add" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在为《%s》添加批注…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.comment_add",
DocID: docName,
Role: payload["role"],
Section: payload["target_section"],
Comment: payload["comment_content"],
Reason: payload["comment_type"],
},
}), nil
}
if targetAction == "confirm_comment_resolve" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在解决《%s》的批注…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.comment_resolve",
DocID: docName,
Role: payload["role"],
Section: payload["target_section"],
},
}), nil
}
if targetAction == "confirm_reopen_request" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在提交《%s》的重开申请…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reopen_request",
DocID: docName,
TaskKey: payload["task_key"],
Reason: payload["reopen_reason"],
Role: payload["role"],
},
}), nil
}
if targetAction == "confirm_reopen_approve" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
reqID, _ := strconv.ParseInt(payload["request_id"], 10, 64)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: "正在批准重开申请…",
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reopen_approve",
DocID: docName,
RequestID: reqID,
},
}), nil
}
if targetAction == "cancel" {
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("cancelled")
}
userCtx.applyPending(nil)
return e.applyNarrative(ctx, userCtx, "handleDocGovProposalDecide", "", "这是已确认或已取消后的受理通报。必须保留文档名与动作边界,不得把受理说成已经归档生效。", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已取消针对《%s》的操作,文档状态保持不变。", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
}), nil
}
// 默认分支:禁止假成功——缺确认动作时诚实澄清
clarify := "请明确您要执行的文档操作(例如确认驳回、确认终审批准、确认认领或确认送审)。未确认前不会修改文档状态。"
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: clarify,
NaturalReply: clarify,
UpdatedContext: userCtx,
}, nil
}
func mergeDecidePayload(cand *intent.CandidateIntent, userCtx *UserProjectContext) map[string]string {
out := map[string]string{}
if userCtx != nil && userCtx.InteractiveCard != nil {
for _, a := range userCtx.InteractiveCard.Actions {
if a.IsAffirmativeDefault && a.Payload != nil {
for k, v := range a.Payload {
out[k] = v
}
}
}
}
if userCtx != nil && userCtx.PendingExpectation != nil && userCtx.PendingExpectation.Payload != nil {
for k, v := range userCtx.PendingExpectation.Payload {
out[k] = v
}
}
if cand != nil && cand.FilledSlots != nil {
for k, v := range cand.FilledSlots {
if v != "" {
out[k] = v
}
}
}
return out
}