Files
Agent-System/internal/intent/recognizer_matchers_docgov.go
weichen b5a782bb38 feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
2026-09-25 20:14:10 +00:00

564 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"fmt"
"regexp"
"strings"
)
// matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取
func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent {
// 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT
if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" {
return &CandidateIntent{
Intent: IntentQueryRejectReason,
Confidence: 0.96,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导",
ClarificationQuestion: q,
}
}
return &CandidateIntent{
Intent: IntentQueryRejectReason,
Confidence: 0.96,
FilledSlots: slots,
Reasoning: "用户询问受控文档驳回/退回原因,路由为 QUERY_REJECT_REASON",
}
}
// 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。
if cand := docGovScopePreempt(input, userCtx); cand != nil {
return cand
}
// 1. 驳回 (Review Reject or Final Reject)
if strings.Contains(input, "驳回") {
isFinal := false
if strings.Contains(input, "终审") || strings.Contains(input, "批准") {
isFinal = true
} else if userCtx != nil && (userCtx.DocLifecycleState == "approving" || userCtx.UserDocRole == "approver") {
isFinal = true
}
targetIntent := IntentDocGovReviewReject
if isFinal {
targetIntent = IntentDocGovFinalReject
}
slots := make(map[string]string)
// target_doc
targetDoc := resolveTargetDoc(input, userCtx)
if targetDoc != "" {
slots[SlotTargetDoc] = targetDoc
}
// target_section
section := extractSection(input)
if section != "" {
slots[SlotTargetSection] = section
}
// reject_reason
reason := extractRejectReason(input)
if reason != "" {
slots[SlotRejectReason] = reason
}
confidence := 0.95
reasoning := fmt.Sprintf("用户下达法定治理驳回指令,目标意图为 %s", targetIntent)
clarifyQ := ""
// Stage 0 权限与状态前置剪枝校验
if userCtx != nil {
if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核驳回", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核驳回权限(需审核人角色)。", userCtx.UserDocRole)
} else if isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole)
}
if userCtx.DocLifecycleState == "drafting" {
confidence = 0.90
reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作"
clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。"
}
}
return &CandidateIntent{
Intent: targetIntent,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 2. 终审批准 (Final Approve) — 排除重开语义,避免「批准重开」误入终审
if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") ||
strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不") &&
!strings.Contains(input, "重开") && !strings.Contains(input, "再打开") && !strings.Contains(input, "再修订")) {
targetIntent := IntentDocGovFinalApprove
slots := make(map[string]string)
targetDoc := resolveTargetDoc(input, userCtx)
if targetDoc != "" {
slots[SlotTargetDoc] = targetDoc
}
confidence := 0.95
reasoning := "用户下达受控文档终审批准生效指令"
clarifyQ := ""
if userCtx != nil {
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审批准", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准权限(需批准人角色)。", userCtx.UserDocRole)
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approving" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入终审批准阶段(approving),无法执行终审批准", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入终审阶段,无法执行终审批准。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: targetIntent,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 3. 审核通过 (Review Approve)
if strings.Contains(input, "审核通过") || strings.Contains(input, "审阅通过") || strings.Contains(input, "评审通过") ||
(strings.Contains(input, "通过") && (strings.Contains(input, "审核") || strings.Contains(input, "审阅") || strings.Contains(input, "报告") || strings.Contains(input, "准予") || strings.Contains(input, "环节"))) {
if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") {
targetIntent := IntentDocGovReviewApprove
slots := make(map[string]string)
targetDoc := resolveTargetDoc(input, userCtx)
if targetDoc != "" {
slots[SlotTargetDoc] = targetDoc
}
confidence := 0.95
reasoning := "用户下达受控文档审核通过指令"
clarifyQ := ""
if userCtx != nil {
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核通过", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核通过权限(需审核人角色)。", userCtx.UserDocRole)
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "reviewing" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入审核阶段(reviewing),无法执行审核通过", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入审核阶段,无法执行审核通过。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: targetIntent,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
}
// 4. 认领文档 (Claim) — 查询语气不得路由为写操作
if strings.Contains(input, "认领") {
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "claim") {
slots := make(map[string]string)
if doc := resolveTargetDoc(input, userCtx); doc != "" {
slots[SlotTargetDoc] = doc
}
return &CandidateIntent{
Intent: IntentQueryUserRights,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的文档认领状态询问,不得路由为写操作 DOCGOV_CLAIM",
ClarificationQuestion: "您是想查询该文档当前由谁认领负责吗?我将为您检索认领状态,不会执行认领操作。",
}
}
slots := make(map[string]string)
targetDoc := resolveTargetDoc(input, userCtx)
if targetDoc != "" {
slots[SlotTargetDoc] = targetDoc
}
return &CandidateIntent{
Intent: IntentDocGovClaim,
Confidence: 0.94,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户下达认领文档编制责任人指令",
}
}
// 5. 提交送审 (Submit Review) — 查询语气不得路由为写操作
if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") {
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "submit") {
slots := make(map[string]string)
if doc := resolveTargetDoc(input, userCtx); doc != "" {
slots[SlotTargetDoc] = doc
}
return &CandidateIntent{
Intent: IntentQueryRejectReason,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的送审/审核状态询问,不得路由为写操作 DOCGOV_SUBMIT_REVIEW",
ClarificationQuestion: "您是想查询该文档的送审或审核状态吗?我将为您检索当前流转状态,不会发起新的送审。",
}
}
slots := make(map[string]string)
targetDoc := resolveTargetDoc(input, userCtx)
if targetDoc != "" {
slots[SlotTargetDoc] = targetDoc
}
return &CandidateIntent{
Intent: IntentDocGovSubmitReview,
Confidence: 0.94,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户下达编制完成送交审核指令",
}
}
// 6. 提案裁决 (Proposal Decide)
if strings.Contains(input, "提案") || strings.Contains(input, "修改建议") || strings.Contains(input, "变更建议") {
if strings.Contains(input, "同意") || strings.Contains(input, "采纳") || strings.Contains(input, "拒绝") || strings.Contains(input, "拍板") {
slots := make(map[string]string)
if strings.Contains(input, "拒绝") {
slots[SlotProposalAction] = "reject"
} else {
slots[SlotProposalAction] = "accept"
}
return &CandidateIntent{
Intent: IntentDocGovProposalDecide,
Confidence: 0.93,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户对受控文档的修改提案或批注做出决策裁决",
}
}
}
// 7. 积压补充查询(只读)— 置于写动作前,避免「积压是什么」被误触
if isPendingSupplementsQuery(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
return &CandidateIntent{
Intent: IntentQueryPendingSupplements,
Confidence: 0.95,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户查询锁定期间积压/待消化补充,路由为只读 QUERY_PENDING_SUPPLEMENTS",
}
}
// 8. 重开批准(须显式「批准/同意」+「重开/再打开/再修订」;裸词「重开」不触发)
if isReopenApprovePhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
confidence := 0.95
reasoning := "用户下达批准重开/再修订指令"
clarifyQ := ""
if userCtx != nil {
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),无法批准重开", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,批准重开需批准人(或创建人兜底,由服务端二次校验)。", userCtx.UserDocRole)
}
if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,重开仅适用于已批准(approved)文档", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请/批准重开。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovReopenApprove,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 9. 重开申请(须「申请」+「重开/再修订/再打开」,或显式文档引用 + 申请语义;裸词「重开」不触发)
if isReopenRequestPhrase(input) {
if detectDialogueAct(input) == DialogueActAsk {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的重开状态询问,不得路由为写操作 DOCGOV_REOPEN_REQUEST",
ClarificationQuestion: "您是想查询该文档是否已批准、可否申请重开吗?我将检索状态,不会发起重开申请。",
}
}
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if reason := extractReopenReason(input); reason != "" {
slots[SlotReopenReason] = reason
}
confidence := 0.94
reasoning := "用户下达申请再修订/重开指令"
clarifyQ := ""
if slots[SlotReopenReason] == "" {
confidence = 0.90
clarifyQ = "请补充重开原因(例如:申请重开《需求表》,原因:需补充风险分析章节)。"
reasoning = "重开申请缺少原因槽位,需澄清"
}
if userCtx != nil {
role := userCtx.UserDocRole
if role != "" && role != "author" && role != "creator" && role != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色为【%s】,申请重开通常需作者/职责人(服务端将二次校验)", role)
clarifyQ = fmt.Sprintf("您当前角色为【%s】。申请重开需为文档职责人或项目创建人;确认后由服务端校验。", role)
}
if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,仅已批准(approved)可申请重开", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请重开。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovReopenRequest,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 10. 批注解决/关闭
if isCommentResolvePhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if sec := extractSection(input); sec != "" {
slots[SlotTargetSection] = sec
}
return &CandidateIntent{
Intent: IntentDocGovCommentResolve,
Confidence: 0.93,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户下达解决/关闭批注意图",
}
}
// 11. 添加批注(须批注词 + 治理动词共现;查询语气走只读;缺内容澄清)
if isCommentAddPhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if sec := extractSection(input); sec != "" {
slots[SlotTargetSection] = sec
}
if ct := extractCommentType(input); ct != "" {
slots[SlotCommentType] = ct
} else {
slots[SlotCommentType] = "suggestion"
}
if content := extractCommentContent(input); content != "" {
slots[SlotCommentContent] = content
}
if detectDialogueAct(input) == DialogueActAsk || isCommentStatusQuery(input) {
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的批注询问,不得路由为写操作 DOCGOV_COMMENT_ADD",
ClarificationQuestion: "您是想查询该文档已有批注吗?我将检索批注列表,不会新增批注。",
}
}
confidence := 0.94
reasoning := "用户下达添加批注/意见指令"
clarifyQ := ""
if slots[SlotCommentContent] == "" {
confidence = 0.90
clarifyQ = "请说明要添加的批注内容(例如:给第3节加条意见:风险分析缺少残留风险评价)。"
reasoning = "批注意图缺少内容槽位,需澄清"
}
if userCtx != nil {
if !commentRoleStateAllowed(userCtx.UserDocRole, userCtx.DocLifecycleState) {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:角色【%s】在阶段【%s】不允许批注(按批注矩阵)", userCtx.UserDocRole, userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("按批注矩阵,角色【%s】在【%s】阶段不可添加批注。", userCtx.UserDocRole, userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovCommentAdd,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
return nil
}
func isPendingSupplementsQuery(input string) bool {
markers := []string{
"待消化的补充", "待消化补充", "积压补充", "锁定期间补充", "锁定期补充",
"积压的补充", "未消化补充", "pending supplement",
}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
if strings.Contains(input, "积压") && (strings.Contains(input, "补充") || strings.Contains(input, "是什么") || strings.Contains(input, "有哪些") || strings.Contains(input, "查询")) {
return true
}
return false
}
func isReopenApprovePhrase(input string) bool {
hasApprove := strings.Contains(input, "批准重开") || strings.Contains(input, "同意重开") ||
strings.Contains(input, "批准再打开") || strings.Contains(input, "同意再打开") ||
strings.Contains(input, "批准再修订") || strings.Contains(input, "同意再修订")
if hasApprove {
return true
}
hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订")
hasApproveVerb := strings.Contains(input, "批准") || strings.Contains(input, "同意")
return hasReopen && hasApproveVerb && !strings.Contains(input, "申请")
}
func isReopenRequestPhrase(input string) bool {
// 裸词「重开」不触发
if strings.TrimSpace(input) == "重开" {
return false
}
explicit := []string{"申请重开", "申请再修订", "申请再打开", "申请重新打开", "请求重开", "请求再修订"}
for _, p := range explicit {
if strings.Contains(input, p) {
return true
}
}
hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订")
hasApply := strings.Contains(input, "申请") || strings.Contains(input, "请求")
if hasReopen && hasApply {
return true
}
// 显式文档引用 + 再打开/再修订(仍须避免裸词)
if hasReopen && extractDocName(input) != "" && (strings.Contains(input, "再") || strings.Contains(input, "申请")) {
return true
}
return false
}
func isCommentResolvePhrase(input string) bool {
markers := []string{"解决批注", "关闭批注", "批注已解决", "批注已处理", "标记批注已解决", "resolve批注"}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
return (strings.Contains(input, "批注") || strings.Contains(input, "意见")) &&
(strings.Contains(input, "解决") || strings.Contains(input, "关闭")) &&
!strings.Contains(input, "添加") && !strings.Contains(input, "加条")
}
func isCommentAddPhrase(input string) bool {
// 批注词必须与治理动词共现,避免「批注」裸词误触
govVerbs := []string{"添加批注", "加条意见", "加条批注", "加上批注", "写条批注", "写个批注", "新增批注", "添加意见", "给.*加.*意见", "批注:", "批注:"}
for _, v := range govVerbs {
if strings.Contains(v, ".*") {
continue
}
if strings.Contains(input, v) {
return true
}
}
hasCommentWord := strings.Contains(input, "批注") || strings.Contains(input, "加条意见") || strings.Contains(input, "添加意见")
hasAction := strings.Contains(input, "添加") || strings.Contains(input, "加条") || strings.Contains(input, "加上") ||
strings.Contains(input, "写") || strings.Contains(input, "新增") || strings.Contains(input, "给")
return hasCommentWord && hasAction
}
func isCommentStatusQuery(input string) bool {
markers := []string{"有哪些批注", "有什么批注", "批注列表", "查看批注", "查一下批注", "我的批注"}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
return false
}
func extractCommentContent(input string) string {
if r := extractRejectReason(input); r != "" {
return r
}
re := regexp.MustCompile(`(?:添加批注|加条意见|加条批注|加上批注|写条批注|写个批注|新增批注|添加意见)[::\s]*(.+)`)
if match := re.FindStringSubmatch(input); len(match) > 1 {
return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ")
}
return ""
}
func extractCommentType(input string) string {
switch {
case strings.Contains(input, "问题") || strings.Contains(input, "issue"):
return "issue"
case strings.Contains(input, "同意") || strings.Contains(input, "approval") || strings.Contains(input, "认可"):
return "approval"
case strings.Contains(input, "建议") || strings.Contains(input, "suggestion"):
return "suggestion"
default:
return ""
}
}
func extractReopenReason(input string) string {
re := regexp.MustCompile(`(?:原因|理由|因为|由于)[::\s]*(.+)`)
if match := re.FindStringSubmatch(input); len(match) > 1 {
return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ")
}
return ""
}
// commentRoleStateAllowed mirrors docgov.roleAllowedToComment for Stage-0 pruning.
func commentRoleStateAllowed(role, state string) bool {
if role == "" || state == "" {
return true // 快照缺失时不剪枝,交给服务端二次校验
}
switch state {
case "drafting":
return role == "author" || role == "reviewer" || role == "system"
case "reviewing":
return role == "reviewer" || role == "author" || role == "system"
case "approving":
return role == "approver" || role == "reviewer" || role == "system"
case "approved":
return false
default:
return false
}
}