package intent_test import ( "context" "strings" "testing" "chat-agent-system/internal/intent" "chat-agent-system/internal/workflowscope" ) // ---------------------------------------------------------------------------------- // 工作流作用域填槽:target_doc / target_task 只命中激活工作流;跨流点名拦截引导 // ---------------------------------------------------------------------------------- func TestWorkflowScope_TriggerTaskInActiveFlow(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_bd", ActiveWorkflowKey: "main"} // 激活商务流:调研报告属 main,正常填 task res, err := rec.Recognize(context.Background(), "帮我生成调研报告", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.Intent != intent.IntentTriggerTask { t.Fatalf("want TRIGGER_TASK, got %s", res.TopIntent.Intent) } if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" { t.Fatalf("in-scope research want research, got %q", got) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "" { t.Fatalf("in-scope task must not carry mismatch marker") } } func TestWorkflowScope_TriggerTaskCrossFlowBlocked(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_bd2", ActiveWorkflowKey: "main"} // 激活商务流却说「编写项目建议书」(研发流 rd_proposal)→ 拦截引导 res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) if err != nil { t.Fatal(err) } slots := res.TopIntent.FilledSlots if slots[intent.SlotWorkflowScopeMismatch] != "true" { t.Fatalf("cross-flow write must be blocked, slots=%v", slots) } if slots[intent.SlotTargetTask] != "" || slots[intent.SlotTargetDoc] != "" { t.Fatalf("cross-flow must not fill target_doc/target_task, got %v", slots) } if !strings.Contains(res.TopIntent.ClarificationQuestion, "切换") { t.Fatalf("guidance should mention 切换, got %q", res.TopIntent.ClarificationQuestion) } if !strings.Contains(res.TopIntent.ClarificationQuestion, "研发设计控制流程") { t.Fatalf("guidance should name owning flow, got %q", res.TopIntent.ClarificationQuestion) } } func TestWorkflowScope_TriggerTaskAfterSwitch(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_rd", ActiveWorkflowKey: "rdev"} // 激活研发流后同名操作 → 正常执行 res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.Intent != intent.IntentTriggerTask { t.Fatalf("want TRIGGER_TASK after switch, got %s", res.TopIntent.Intent) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { t.Fatalf("must not block in owning flow, slots=%v", res.TopIntent.FilledSlots) } if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "rd_proposal" { t.Fatalf("want rd_proposal, got %q", got) } } func TestWorkflowScope_DocGovWriteCrossFlowBlocked(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{ SnapshotID: "s_pm", ActiveWorkflowKey: "product_manager", UserDocRole: "reviewer", } // 激活产品经理流,却驳回商务流文档「调研报告」→ 治理写动作被前置剪枝拦截 res, err := rec.Recognize(context.Background(), "把调研报告审核驳回", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.Intent == intent.IntentDocGovReviewReject { t.Fatalf("cross-flow governance reject must be blocked, got %s", res.TopIntent.Intent) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { t.Fatalf("expected mismatch marker, slots=%v", res.TopIntent.FilledSlots) } if !res.RequiresClarifying { t.Fatalf("cross-flow block must require clarification") } } func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) { rec := intent.NewRecognizer() // 存量单流 / planning 态:无激活工作流 → 不过滤,行为与修复前一致 ctx := &intent.ContextSnapshot{SnapshotID: "s_legacy"} res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { t.Fatalf("no active workflow must never block, got %v", res.TopIntent.FilledSlots) } if res.TopIntent.Intent != intent.IntentTriggerTask { t.Fatalf("want TRIGGER_TASK passthrough, got %s", res.TopIntent.Intent) } } func TestWorkflowScope_WeightedInScopeBeatsCrossPhrase(t *testing.T) { workflowscope.SetModeForTest(workflowscope.ModeWeighted) t.Cleanup(workflowscope.ResetModeForTest) rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_w", ActiveWorkflowKey: "main"} res, err := rec.Recognize(context.Background(), "生成调研报告,不要写成项目建议书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { t.Fatalf("in-scope research must not be hijacked, slots=%v", res.TopIntent.FilledSlots) } if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" { t.Fatalf("want research, got %q slots=%v", got, res.TopIntent.FilledSlots) } } func TestWorkflowScope_WeightedReadCrossAllowed(t *testing.T) { workflowscope.SetModeForTest(workflowscope.ModeWeighted) t.Cleanup(workflowscope.ResetModeForTest) rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_r", ActiveWorkflowKey: "main"} res, err := rec.Recognize(context.Background(), "查看项目建议书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { t.Fatalf("weighted read must not block, slots=%v", res.TopIntent.FilledSlots) } if res.TopIntent.FilledSlots[intent.SlotCrossWorkflowOwner] != "rdev" { t.Fatalf("want rdev owner, slots=%v", res.TopIntent.FilledSlots) } if !strings.Contains(res.TopIntent.FilledSlots[intent.SlotTargetDoc], "项目建议书") { t.Fatalf("want target doc, slots=%v", res.TopIntent.FilledSlots) } } func TestWorkflowScope_WeightedWriteStillBlocked(t *testing.T) { workflowscope.SetModeForTest(workflowscope.ModeWeighted) t.Cleanup(workflowscope.ResetModeForTest) rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_wb", ActiveWorkflowKey: "main"} res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { t.Fatalf("weighted write must still block, slots=%v intent=%s", res.TopIntent.FilledSlots, res.TopIntent.Intent) } } func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) { // ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。 snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"} if snap.ActiveWorkflowKey != "main" { t.Fatalf("ActiveWorkflowKey must be settable, got %q", snap.ActiveWorkflowKey) } } func TestWriteVerb_DingDesignPlanCrossFlow(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_ding", ActiveWorkflowKey: "main"} res, err := rec.Recognize(context.Background(), "帮我定设计开发计划任务书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.Intent != intent.IntentTriggerTask { t.Fatalf("want TRIGGER_TASK, got %s reasoning=%s", res.TopIntent.Intent, res.TopIntent.Reasoning) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { t.Fatalf("main flow must block rd_design_plan, slots=%v", res.TopIntent.FilledSlots) } if !strings.Contains(res.TopIntent.ClarificationQuestion, "研发设计控制流程") { t.Fatalf("guidance=%q", res.TopIntent.ClarificationQuestion) } if res.Branch != intent.BranchAmbiguousFallback && !res.RequiresClarifying { t.Fatalf("cross-flow write should require clarifying, branch=%s", res.Branch) } } func TestWriteVerb_ZhidingDesignPlanOnRdev(t *testing.T) { rec := intent.NewRecognizer() ctx := &intent.ContextSnapshot{SnapshotID: "s_zhiding", ActiveWorkflowKey: "rdev"} res, err := rec.Recognize(context.Background(), "帮我制定设计开发计划任务书", ctx) if err != nil { t.Fatal(err) } if res.TopIntent.Intent != intent.IntentTriggerTask { t.Fatalf("want TRIGGER_TASK, got %s", res.TopIntent.Intent) } if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { t.Fatalf("owning flow must not block, slots=%v", res.TopIntent.FilledSlots) } if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "rd_design_plan" { t.Fatalf("task=%q", got) } } func TestWriteVerb_DecideIsNotWrite(t *testing.T) { rec := intent.NewRecognizer() res, err := rec.Recognize(context.Background(), "我决定先放一放,再看看进展", nil) if err != nil { t.Fatal(err) } if res.TopIntent.Intent == intent.IntentTriggerTask { t.Fatalf("决定 must not trigger a deliverable, slots=%v", res.TopIntent.FilledSlots) } }