diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 28454343..1ee82135 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -73,19 +73,19 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC }, nil } - // 2. 纯 UI 视窗控制指令拦截:归入 CASUAL_CHAT 并给出不可执行提示 + // 2. 纯 UI 视窗与主题控制指令拦截:归入 CASUAL_CHAT 并给出不可执行提示 if isPureUICommand(trimmed) { top := &CandidateIntent{ - Intent: IntentCasualChat, - Branch: BranchAmbiguousFallback, - Confidence: 0.90, + Intent: IntentCasualChat, + Branch: BranchNewIntentPreempt, + Confidence: 0.95, FilledSlots: make(map[string]string), - Reasoning: "纯UI视窗控制指令,系统暂不支持通过对话直接操作界面视窗布局,请通过界面按钮或快捷键操作", - ClarificationQuestion: "检测到您下达了界面视窗控制指令(如折叠/打开侧边栏)。该操作属于前端UI视窗布局控制,请直接点击界面右上角或侧边的视窗切换按钮。", + Reasoning: "纯UI视窗或主题控制指令,系统暂不支持通过对话直接操作界面视窗布局或主题,请通过界面按钮或快捷键操作", + ClarificationQuestion: "检测到您下达了界面视窗或主题控制指令(如折叠/打开侧边栏、切换主题/深色浅色)。该操作属于前端UI视窗布局与主题控制,请直接点击界面右上角或侧边的视窗/主题切换按钮。", } return &IntentRecognitionResult{ RawInput: trimmed, - Branch: BranchAmbiguousFallback, + Branch: BranchNewIntentPreempt, TopIntent: top, Candidates: []CandidateIntent{*top}, ContextSnapshotID: snapID, @@ -183,8 +183,8 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC branch := BranchNewIntentPreempt requiresClarifying := false - // 1. 若为 DOCGOV_REVIEW_REJECT,检查必填槽位 reject_reason - if top.Intent == IntentDocGovReviewReject { + // 1. 若为 DOCGOV_REVIEW_REJECT 或 DOCGOV_FINAL_REJECT,检查必填槽位 reject_reason + if top.Intent == IntentDocGovReviewReject || top.Intent == IntentDocGovFinalReject { if top.FilledSlots[SlotRejectReason] == "" { requiresClarifying = true top.ClarificationQuestion = "请说明具体的法定驳回理由,我将为您注入文档批注并退回编制阶段。" @@ -218,11 +218,17 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC func isSecurityViolation(input string) bool { lower := strings.ToLower(input) patterns := []string{ - "修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", + "修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", "岗位修改", + "修改姓名", "姓名修改", "修改名字", "名字修改", + "修改部门", "部门修改", + "修改资质", "资质修改", "改成批准人", "改成审核人", "改成编制人", "工号改成", "修改工号", "工号修改", - "修改设置", "修改配置", "系统设置", "修改网关", "网关配置", + "修改设置", "修改配置", "系统设置", "系统配置", + "修改网关", "网关配置", "网关密钥", "大模型网关", "模型网关", "修改密码", "重置密码", "提升权限", "越权", + "修改向量库", "向量库连接", "向量库配置", "修改向量库连接", + "修改知识库连接", "知识库连接", "知识库配置", } for _, p := range patterns { if strings.Contains(lower, p) { @@ -231,8 +237,9 @@ func isSecurityViolation(input string) bool { } secRegexes := []string{ - `把.*(?:身份|职位|角色|工号|权限).*(?:修改|改|换)`, - `(?:修改|改|换).*(?:身份|职位|角色|工号|网关)`, + `把.*(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质).*(?:修改|改|换|重置|设为|变为)`, + `(?:修改|改|换|重置|设为).*(?:我的)?(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质|网关|向量库|知识库连接|密钥|apikey|api_key)`, + `.*(?:向量库连接|修改向量库|知识库连接|大模型网关密钥|网关密钥).*`, } for _, r := range secRegexes { if matched, _ := regexp.MatchString(r, input); matched { @@ -242,7 +249,7 @@ func isSecurityViolation(input string) bool { return false } -// isPureUICommand 检查是否为纯前端UI视窗布局控制指令 +// isPureUICommand 检查是否为纯前端UI视窗布局与主题控制指令 func isPureUICommand(input string) bool { patterns := []string{ "打开工具栏", "关闭工具栏", @@ -251,6 +258,9 @@ func isPureUICommand(input string) bool { "折叠工作台", "展开工作台", "切换全屏", "全屏显示", "退出全屏", "打开控制台", "关闭控制台", + "切换主题", "换成深色", "换成浅色", + "打开抽屉", "展开抽屉", "关闭抽屉", + "深色模式", "浅色模式", "深色主题", "浅色主题", } for _, p := range patterns { if strings.Contains(input, p) { @@ -260,6 +270,17 @@ func isPureUICommand(input string) bool { return false } +// resolveTargetDoc 优先从输入中提取显式受控文档名称,提取不到时回退至上下文的 ActiveDocID +func resolveTargetDoc(input string, userCtx *ContextSnapshot) string { + if doc := extractDocName(input); doc != "" { + return doc + } + if userCtx != nil && userCtx.ActiveDocID != "" { + return userCtx.ActiveDocID + } + return "" +} + // matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取 func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent { // 1. 驳回 (Review Reject or Final Reject) @@ -279,12 +300,7 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent slots := make(map[string]string) // target_doc - targetDoc := "" - if userCtx != nil && userCtx.ActiveDocID != "" { - targetDoc = userCtx.ActiveDocID - } else { - targetDoc = extractDocName(input) - } + targetDoc := resolveTargetDoc(input, userCtx) if targetDoc != "" { slots[SlotTargetDoc] = targetDoc } @@ -308,17 +324,17 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent // Stage 0 权限与状态前置剪枝校验 if userCtx != nil { if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" { - confidence = 0.55 + 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.55 + confidence = 0.90 reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole) clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole) } if userCtx.DocLifecycleState == "drafting" { - confidence = 0.55 + confidence = 0.90 reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作" clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。" } @@ -339,22 +355,34 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) { targetIntent := IntentDocGovFinalApprove slots := make(map[string]string) - targetDoc := "" - if userCtx != nil && userCtx.ActiveDocID != "" { - targetDoc = userCtx.ActiveDocID - } else { - targetDoc = extractDocName(input) - } + 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: 0.95, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户下达受控文档终审批准生效指令", + Intent: targetIntent, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, } } @@ -364,22 +392,34 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") { targetIntent := IntentDocGovReviewApprove slots := make(map[string]string) - targetDoc := "" - if userCtx != nil && userCtx.ActiveDocID != "" { - targetDoc = userCtx.ActiveDocID - } else { - targetDoc = extractDocName(input) - } + 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: 0.95, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户下达受控文档审核通过指令", + Intent: targetIntent, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, } } } @@ -387,12 +427,7 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent // 4. 认领文档 (Claim) if strings.Contains(input, "认领") { slots := make(map[string]string) - targetDoc := "" - if userCtx != nil && userCtx.ActiveDocID != "" { - targetDoc = userCtx.ActiveDocID - } else { - targetDoc = extractDocName(input) - } + targetDoc := resolveTargetDoc(input, userCtx) if targetDoc != "" { slots[SlotTargetDoc] = targetDoc } @@ -409,12 +444,7 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent // 5. 提交送审 (Submit Review) if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") { slots := make(map[string]string) - targetDoc := "" - if userCtx != nil && userCtx.ActiveDocID != "" { - targetDoc = userCtx.ActiveDocID - } else { - targetDoc = extractDocName(input) - } + targetDoc := resolveTargetDoc(input, userCtx) if targetDoc != "" { slots[SlotTargetDoc] = targetDoc } @@ -567,7 +597,7 @@ func extractRejectReason(input string) string { return strings.Trim(res, ",,。;; ") } - reAfterReject := regexp.MustCompile(`驳回[,,\s]+(.+)`) + reAfterReject := regexp.MustCompile(`驳回[,,::\s]+(.+)`) if match := reAfterReject.FindStringSubmatch(input); len(match) > 1 { res := strings.TrimSpace(match[1]) res = regexp.MustCompile(`^(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*`).ReplaceAllString(res, "") diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 67d87b52..319fe477 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -433,6 +433,12 @@ func TestIntentRecognition_PureUICommand(t *testing.T) { if res.TopIntent.Intent != intent.IntentCasualChat { t.Errorf("expected intent CASUAL_CHAT for pure UI command, got %s", res.TopIntent.Intent) } + if res.Branch != intent.BranchNewIntentPreempt { + t.Errorf("expected branch NEW_INTENT_PREEMPT, got %s", res.Branch) + } + if res.TopIntent.Confidence != 0.95 { + t.Errorf("expected confidence 0.95, got %v", res.TopIntent.Confidence) + } if !strings.Contains(res.TopIntent.Reasoning, "UI") && !strings.Contains(res.TopIntent.ClarificationQuestion, "UI") { t.Errorf("expected reasoning or clarification question to mention UI, got reasoning: %s", res.TopIntent.Reasoning) } @@ -492,3 +498,213 @@ func TestIntentRecognition_DocGov_OtherActions(t *testing.T) { t.Errorf("expected intent DOCGOV_FINAL_APPROVE, got %s", resFinal.TopIntent.Intent) } } + +func TestIntentRecognition_FinalReject_MissingReason(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_final_rej_1", + CurrentStage: string(bizdev.StageV05_Feasibility), + ActiveDocID: "doc-002", + DocLifecycleState: "approving", + UserDocRole: "approver", + } + + res, err := rec.Recognize(context.Background(), "把调研报告终审驳回", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.TopIntent.Intent != intent.IntentDocGovFinalReject { + t.Errorf("expected intent DOCGOV_FINAL_REJECT, got %s", res.TopIntent.Intent) + } + if !res.RequiresClarifying { + t.Errorf("expected RequiresClarifying to be true when reject_reason is missing in final reject") + } + expectedClarify := "请说明具体的法定驳回理由,我将为您注入文档批注并退回编制阶段。" + if res.TopIntent.ClarificationQuestion != expectedClarify { + t.Errorf("expected clarification question '%s', got '%s'", expectedClarify, res.TopIntent.ClarificationQuestion) + } + if res.TopIntent.FilledSlots[intent.SlotRejectReason] != "" { + t.Errorf("expected empty reject_reason slot, got %s", res.TopIntent.FilledSlots[intent.SlotRejectReason]) + } + if res.Branch != intent.BranchAmbiguousFallback { + t.Errorf("expected branch AMBIGUOUS_FALLBACK when slot is missing, got %s", res.Branch) + } +} + +func TestIntentRecognition_SecurityForbidden_Extended(t *testing.T) { + rec := intent.NewRecognizer() + + testInputs := []struct { + input string + desc string + }{ + {"把姓名修改为李工", "姓名修改"}, + {"修改我的部门为微创中心", "部门修改"}, + {"把资质改成主任医师", "资质修改"}, + {"修改向量库连接", "向量库连接修改"}, + {"修改大模型网关密钥", "网关密钥修改"}, + {"把知识库连接改为192.168.1.100:5432", "知识库连接修改"}, + } + + for _, tc := range testInputs { + t.Run(tc.desc, func(t *testing.T) { + res, err := rec.Recognize(context.Background(), tc.input, nil) + if err != nil { + t.Fatalf("recognize '%s' failed: %v", tc.input, err) + } + if res.TopIntent.Intent != intent.IntentSecurityForbidden { + t.Errorf("input '%s': expected intent SECURITY_FORBIDDEN, got %s", tc.input, res.TopIntent.Intent) + } + if res.TopIntent.Confidence != 1.0 { + t.Errorf("input '%s': expected confidence 1.0, got %v", tc.input, res.TopIntent.Confidence) + } + if res.Branch != intent.BranchNewIntentPreempt { + t.Errorf("input '%s': expected branch NEW_INTENT_PREEMPT, got %s", tc.input, res.Branch) + } + }) + } +} + +func TestIntentRecognition_ExplicitDocName_OverActiveDocID(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_explicit_doc", + CurrentStage: string(bizdev.StageV05_Feasibility), + ActiveDocID: "doc-001-intake", // 上下文中当前文档是需求收集表 + DocLifecycleState: "reviewing", + UserDocRole: "reviewer", + } + + // 显式指定文档名称为“调研报告”,应优先提取“调研报告”而不是上下文的“doc-001-intake” + res, err := rec.Recognize(context.Background(), "把调研报告审核驳回:缺少抗扰度指标", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.TopIntent.Intent != intent.IntentDocGovReviewReject { + t.Errorf("expected intent DOCGOV_REVIEW_REJECT, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotTargetDoc] != "调研报告" { + t.Errorf("expected target_doc '调研报告', got '%s'", res.TopIntent.FilledSlots[intent.SlotTargetDoc]) + } + if res.TopIntent.FilledSlots[intent.SlotRejectReason] != "缺少抗扰度指标" { + t.Errorf("expected reject_reason '缺少抗扰度指标', got '%s'", res.TopIntent.FilledSlots[intent.SlotRejectReason]) + } + + // 显式指定文档 ID + resDocID, err := rec.Recognize(context.Background(), "把 doc-003 审核通过", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if resDocID.TopIntent.FilledSlots[intent.SlotTargetDoc] != "doc-003" { + t.Errorf("expected target_doc 'doc-003', got '%s'", resDocID.TopIntent.FilledSlots[intent.SlotTargetDoc]) + } + + // 未显式指定文档名称,应回退使用 ActiveDocID + resFallback, err := rec.Recognize(context.Background(), "审核通过,同意进入下一环节", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if resFallback.TopIntent.FilledSlots[intent.SlotTargetDoc] != "doc-001-intake" { + t.Errorf("expected fallback target_doc 'doc-001-intake', got '%s'", resFallback.TopIntent.FilledSlots[intent.SlotTargetDoc]) + } +} + +func TestIntentRecognition_RejectReason_ColonSeparators(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + ActiveDocID: "doc-002", + DocLifecycleState: "reviewing", + UserDocRole: "reviewer", + } + + // 全角冒号 + resCN, err := rec.Recognize(context.Background(), "把调研报告审核驳回:缺少抗扰度指标", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if resCN.TopIntent.FilledSlots[intent.SlotRejectReason] != "缺少抗扰度指标" { + t.Errorf("expected reject_reason '缺少抗扰度指标', got '%s'", resCN.TopIntent.FilledSlots[intent.SlotRejectReason]) + } + + // 半角冒号 + resEN, err := rec.Recognize(context.Background(), "把调研报告审核驳回:缺少抗扰度指标", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if resEN.TopIntent.FilledSlots[intent.SlotRejectReason] != "缺少抗扰度指标" { + t.Errorf("expected reject_reason '缺少抗扰度指标', got '%s'", resEN.TopIntent.FilledSlots[intent.SlotRejectReason]) + } +} + +func TestIntentRecognition_PureUICommands_Extended(t *testing.T) { + rec := intent.NewRecognizer() + + uiCommands := []string{ + "切换主题", + "换成深色", + "换成浅色", + "打开抽屉", + "展开抽屉", + "关闭抽屉", + "请帮我折叠侧边栏", + "展开侧边栏", + "打开工具栏", + "切换全屏", + } + + for _, cmd := range uiCommands { + t.Run(cmd, func(t *testing.T) { + res, err := rec.Recognize(context.Background(), cmd, nil) + if err != nil { + t.Fatalf("recognize '%s' failed: %v", cmd, err) + } + if res.TopIntent.Intent != intent.IntentCasualChat { + t.Errorf("cmd '%s': expected intent CASUAL_CHAT, got %s", cmd, res.TopIntent.Intent) + } + if res.Branch != intent.BranchNewIntentPreempt { + t.Errorf("cmd '%s': expected branch NEW_INTENT_PREEMPT, got %s", cmd, res.Branch) + } + if res.TopIntent.Confidence != 0.95 { + t.Errorf("cmd '%s': expected confidence 0.95, got %v", cmd, res.TopIntent.Confidence) + } + if res.RequiresClarifying { + t.Errorf("cmd '%s': expected RequiresClarifying to be false", cmd) + } + }) + } +} + +func TestIntentRecognition_PermissionMismatch_NotHijackedBySupplement(t *testing.T) { + rec := intent.NewRecognizer() + // 用户是非审核人角色,但输入包含“修改为”(如果未拦截会被 SUPPLEMENT_INFO 0.88 抢占) + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_unauth_supp", + ActiveDocID: "doc-002", + DocLifecycleState: "reviewing", + UserDocRole: "author", // 非审核人 + } + + res, err := rec.Recognize(context.Background(), "把调研报告审核驳回,修改为第三阶段指标", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + // 必须保持 DOCGOV_REVIEW_REJECT,不能被 SUPPLEMENT_INFO 抢占 + if res.TopIntent.Intent != intent.IntentDocGovReviewReject { + t.Errorf("expected intent DOCGOV_REVIEW_REJECT to stay top, got %s", res.TopIntent.Intent) + } + if res.TopIntent.Confidence < 0.90 { + t.Errorf("expected confidence >= 0.90, got %v", res.TopIntent.Confidence) + } + if !res.RequiresClarifying { + t.Errorf("expected RequiresClarifying to be true") + } + if !strings.Contains(res.TopIntent.Reasoning, "权限不符") { + t.Errorf("expected reasoning to contain '权限不符', got %s", res.TopIntent.Reasoning) + } + if res.Branch != intent.BranchAmbiguousFallback { + t.Errorf("expected branch AMBIGUOUS_FALLBACK, got %s", res.Branch) + } +}