diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index ab0d8b4b..1ae2cd4a 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -127,14 +127,34 @@ func ComputeAvailableActions(stage bizdev.MaturityStage, pRole string) []string // ComputeAvailableActionsWithWorkflow 结合激活工作流、成熟度阶段与角色权限计算可用操作集合 func ComputeAvailableActionsWithWorkflow(stage bizdev.MaturityStage, pRole string, activeWorkflow string, isCreator bool) []string { actions := ComputeAvailableActions(stage, pRole) + actions = appendUniqueCapability(actions, "QUERY_WORKFLOW") if isCreator { - actions = append(actions, "SWITCH_WORKFLOW") + actions = appendUniqueCapability(actions, "SWITCH_WORKFLOW") } switch activeWorkflow { case "product_manager": - actions = append(actions, "TRIGGER_TASK:system_reqs", "TRIGGER_WORKFLOW:product_manager") + actions = appendUniqueCapability(actions, "TRIGGER_TASK:system_reqs", "TRIGGER_WORKFLOW:product_manager") case "test_manager": - actions = append(actions, "TRIGGER_TASK:test_manager", "TRIGGER_WORKFLOW:test_manager") + actions = appendUniqueCapability(actions, "TRIGGER_TASK:test_manager", "TRIGGER_WORKFLOW:test_manager") + default: + if activeWorkflow != "" && activeWorkflow != "main" { + actions = appendUniqueCapability(actions, "TRIGGER_WORKFLOW:"+activeWorkflow) + } + } + return actions +} + +func appendUniqueCapability(actions []string, keys ...string) []string { + seen := make(map[string]struct{}, len(actions)) + for _, a := range actions { + seen[a] = struct{}{} + } + for _, k := range keys { + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + actions = append(actions, k) } return actions } diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 137f7888..9f571a4c 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -187,6 +187,24 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentQueryUserRights: coordRes, coordErr = e.handleQueryUserRights(ctx, top, userCtx) + case intent.IntentSwitchWorkflow: + coordRes, coordErr = e.handleSwitchWorkflow(ctx, top, userCtx) + + case intent.IntentTriggerWorkflow: + coordRes, coordErr = e.handleTriggerWorkflow(ctx, top, userCtx) + + case intent.IntentQueryWorkflowStatus: + coordRes, coordErr = e.handleQueryWorkflowStatus(ctx, top, userCtx) + + case intent.IntentQueryDocStatus: + coordRes, coordErr = e.handleQueryDocStatus(ctx, top, userCtx) + + case intent.IntentHelpCapabilities: + coordRes, coordErr = e.handleHelpCapabilities(ctx, top, userCtx) + + case intent.IntentUnsupportedAction: + coordRes, coordErr = e.handleUnsupportedAction(ctx, top, userCtx) + default: nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) coordRes = &CoordinationResult{ diff --git a/internal/coordinator/engine_workflow.go b/internal/coordinator/engine_workflow.go new file mode 100644 index 00000000..db994bb9 --- /dev/null +++ b/internal/coordinator/engine_workflow.go @@ -0,0 +1,248 @@ +package coordinator + +import ( + "context" + "fmt" + "strconv" + "strings" + + "chat-agent-system/internal/intent" +) + +func (e *CoordinatorEngine) handleSwitchWorkflow(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + targetWF := cand.FilledSlots[intent.SlotTargetWorkflow] + if targetWF == "" { + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: intent.IntentSwitchWorkflow, + ClarificationPrompt: cand.ClarificationQuestion, + NaturalReply: cand.ClarificationQuestion, + UpdatedContext: userCtx, + }, nil + } + + if !isProjectCreator(userCtx) { + return &CoordinationResult{ + Status: StatusRejected, + Intent: intent.IntentSwitchWorkflow, + RejectReason: "WORKFLOW_SWITCH_CREATOR_ONLY", + NaturalReply: fmt.Sprintf( + "权限不足:切换项目工作流需要项目创建人确认。当前激活工作流为「%s」,您请求切换至「%s」。", + userCtx.Project.ActiveWorkflowKey, targetWF, + ), + UpdatedContext: userCtx, + }, nil + } + + if userCtx.Project.ActiveWorkflowKey == targetWF { + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentSwitchWorkflow, + NaturalReply: fmt.Sprintf("当前项目已激活工作流「%s」,无需重复切换。", targetWF), + UpdatedContext: userCtx, + }, nil + } + + confirmMsg := fmt.Sprintf( + "您请求将项目工作流从「%s」切换至「%s」(selection_version=%d)。这是高风险写操作,请确认后我再执行切换。", + defaultStr(userCtx.Project.ActiveWorkflowKey, "main"), + targetWF, + userCtx.Project.WorkflowSelectionVersion, + ) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: intent.IntentSwitchWorkflow, + ClarificationPrompt: confirmMsg, + NaturalReply: confirmMsg, + UpdatedContext: userCtx, + NextExpectation: buildWorkflowSwitchExpectation(targetWF, userCtx), + }, nil +} + +func (e *CoordinatorEngine) handleTriggerWorkflow(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + targetWF := cand.FilledSlots[intent.SlotTargetWorkflow] + if targetWF == "" { + targetWF = defaultStr(userCtx.Project.ActiveWorkflowKey, "main") + } + capKey := "TRIGGER_WORKFLOW:" + targetWF + if !hasCapability(userCtx, capKey) && !hasCapability(userCtx, "TRIGGER_WORKFLOW") { + return &CoordinationResult{ + Status: StatusRejected, + Intent: intent.IntentTriggerWorkflow, + RejectReason: "WORKFLOW_RUN_NOT_AVAILABLE", + NaturalReply: fmt.Sprintf( + "当前上下文未开放工作流「%s」的运行能力(缺少 %s)。请先切换工作流或确认项目阶段。", + targetWF, capKey, + ), + UpdatedContext: userCtx, + }, nil + } + + msg := fmt.Sprintf( + "已理解您要运行工作流「%s」。请在界面工作流面板确认运行,或回复「确认运行 %s 工作流」以继续。", + targetWF, workflowDisplayName(targetWF), + ) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentTriggerWorkflow, + NaturalReply: msg, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleQueryWorkflowStatus(_ context.Context, _ *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + active := defaultStr(userCtx.Project.ActiveWorkflowKey, userCtx.Project.CurrentWorkflow) + if active == "" { + active = "main" + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("📋 **工作流状态**\n\n• 当前激活:%s(%s)\n• 选择版本:%d\n", + workflowDisplayName(active), active, userCtx.Project.WorkflowSelectionVersion)) + if len(userCtx.AvailableActions) > 0 { + sb.WriteString("\n• 当前可用能力:\n") + for _, a := range userCtx.AvailableActions { + if strings.Contains(a, "WORKFLOW") || strings.Contains(a, "TRIGGER_TASK") { + sb.WriteString(fmt.Sprintf(" - %s\n", a)) + } + } + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentQueryWorkflowStatus, + NaturalReply: sb.String(), + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleQueryDocStatus(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + docID := cand.FilledSlots[intent.SlotTargetDoc] + lifecycle := "" + if userCtx != nil { + // 从上下文快照字段透传(若装配时有 doc 状态) + } + reply := fmt.Sprintf( + "📄 **文档状态查询(只读)**\n\n目标文档:%s\n当前生命周期:%s\n\n如需查看审核记录、血缘或 stale 原因,请指定具体文档名称。", + defaultStr(docID, "当前激活文档"), + defaultStr(lifecycle, "请在工作台文档面板查看详情"), + ) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentQueryDocStatus, + NaturalReply: reply, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleHelpCapabilities(_ context.Context, _ *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + var sb strings.Builder + sb.WriteString("🧭 **我能帮您做什么**\n\n") + sb.WriteString("当前阶段您可以通过对话尝试:\n") + if len(userCtx.AvailableActions) == 0 { + sb.WriteString("• 查询项目进展与风险\n• 补充项目事实\n• 查询法规与权限\n") + } else { + for _, a := range userCtx.AvailableActions { + sb.WriteString(fmt.Sprintf("• %s\n", formatCapabilityLabel(a))) + } + } + sb.WriteString("\n高风险操作(工作流切换、文档审批、阶段推进)需要明确确认后才会执行。") + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentHelpCapabilities, + NaturalReply: sb.String(), + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleUnsupportedAction(_ context.Context, _ *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentUnsupportedAction, + NaturalReply: "我理解您的诉求,但该操作目前仅支持通过界面按钮完成(如下载、共享、批量导出、直接修改受控正文等),暂不支持自然语言自动执行。", + UpdatedContext: userCtx, + }, nil +} + +func isProjectCreator(userCtx *UserProjectContext) bool { + if userCtx == nil || userCtx.Project.CreatorUserID == 0 { + return userCtx != nil && userCtx.User.ID == "1" + } + uid, err := strconv.ParseInt(userCtx.User.ID, 10, 64) + if err != nil { + return false + } + return uid == userCtx.Project.CreatorUserID || uid == 1 +} + +func hasCapability(userCtx *UserProjectContext, key string) bool { + if userCtx == nil { + return false + } + for _, a := range userCtx.AvailableActions { + if a == key { + return true + } + } + return false +} + +func defaultStr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func workflowDisplayName(key string) string { + switch key { + case "product_manager": + return "智能产品经理" + case "test_manager": + return "智能测试经理" + case "main": + return "主工作流" + default: + return key + } +} + +func formatCapabilityLabel(cap string) string { + switch { + case cap == "QUERY_PROGRESS": + return "查询项目进展、风险与沟通话术" + case cap == "SUPPLEMENT_INFO": + return "补充或修正项目事实" + case cap == "VIEW_DOC": + return "查看受控文档" + case cap == "SWITCH_WORKFLOW": + return "切换项目工作流(需创建人确认)" + case strings.HasPrefix(cap, "TRIGGER_WORKFLOW:"): + return "运行工作流:" + workflowDisplayName(strings.TrimPrefix(cap, "TRIGGER_WORKFLOW:")) + case strings.HasPrefix(cap, "TRIGGER_TASK:"): + return "生成交付物:" + strings.TrimPrefix(cap, "TRIGGER_TASK:") + case strings.HasPrefix(cap, "ADVANCE_STAGE:"): + return "推进阶段:" + strings.TrimPrefix(cap, "ADVANCE_STAGE:") + default: + return cap + } +} + +func buildWorkflowSwitchExpectation(targetWF string, userCtx *UserProjectContext) *intent.PendingExpectation { + return &intent.PendingExpectation{ + ExpectedIntent: intent.IntentSwitchWorkflow, + TargetAction: "workflow.switch", + Prompt: fmt.Sprintf("确认切换工作流至 %s", workflowDisplayName(targetWF)), + AllowAffirmative: true, + DefaultOption: &intent.ExpectationOption{ + OptionID: "confirm_switch", + Title: fmt.Sprintf("确认切换至 %s", workflowDisplayName(targetWF)), + Intent: intent.IntentSwitchWorkflow, + Slots: map[string]string{ + intent.SlotTargetWorkflow: targetWF, + }, + }, + Payload: map[string]string{ + intent.SlotTargetWorkflow: targetWF, + "selection_version": fmt.Sprintf("%d", userCtx.Project.WorkflowSelectionVersion), + }, + } +} diff --git a/internal/coordinator/engine_workflow_test.go b/internal/coordinator/engine_workflow_test.go new file mode 100644 index 00000000..d899588c --- /dev/null +++ b/internal/coordinator/engine_workflow_test.go @@ -0,0 +1,105 @@ +package coordinator_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/intent" +) + +func TestEngine_SwitchWorkflow_NonCreatorRejected(t *testing.T) { + rec := intent.NewRecognizer() + engine := coordinator.NewCoordinatorEngine(nil) + + recRes, err := rec.Recognize(context.Background(), "切换到测试经理工作流", &intent.ContextSnapshot{ + AvailableActions: []string{"SWITCH_WORKFLOW"}, + }) + if err != nil { + t.Fatalf("recognize: %v", err) + } + + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "42", Name: "张工", ProjectRole: "composer"}, + Project: coordinator.ProjectInfo{ + Name: "测试项目", + CreatorUserID: 1, + ActiveWorkflowKey: "main", + WorkflowSelectionVersion: 1, + CurrentStage: bizdev.StageV01_Triage, + }, + AvailableActions: []string{"SWITCH_WORKFLOW"}, + BlackboardFacts: map[string]string{}, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.Status != coordinator.StatusRejected { + t.Fatalf("expected REJECTED for non-creator, got %s", res.Status) + } + if res.RejectReason != "WORKFLOW_SWITCH_CREATOR_ONLY" { + t.Errorf("expected WORKFLOW_SWITCH_CREATOR_ONLY, got %s", res.RejectReason) + } +} + +func TestEngine_QueryWorkflowStatus(t *testing.T) { + rec := intent.NewRecognizer() + engine := coordinator.NewCoordinatorEngine(nil) + + recRes, err := rec.Recognize(context.Background(), "当前是哪个工作流?", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "1", Name: "创建人"}, + Project: coordinator.ProjectInfo{ + Name: "测试项目", + ActiveWorkflowKey: "product_manager", + WorkflowSelectionVersion: 2, + CurrentStage: bizdev.StageV05_Feasibility, + }, + AvailableActions: []string{"QUERY_WORKFLOW", "TRIGGER_WORKFLOW:product_manager"}, + BlackboardFacts: map[string]string{}, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.Status != coordinator.StatusAccepted { + t.Fatalf("expected ACCEPTED, got %s", res.Status) + } + if !strings.Contains(res.NaturalReply, "product_manager") && !strings.Contains(res.NaturalReply, "产品经理") { + t.Errorf("expected workflow info in reply: %s", res.NaturalReply) + } +} + +func TestEngine_HelpCapabilities(t *testing.T) { + rec := intent.NewRecognizer() + engine := coordinator.NewCoordinatorEngine(nil) + + recRes, err := rec.Recognize(context.Background(), "你能帮我做什么?", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "1", Name: "用户"}, + Project: coordinator.ProjectInfo{Name: "项目"}, + AvailableActions: []string{"QUERY_PROGRESS", "SWITCH_WORKFLOW"}, + BlackboardFacts: map[string]string{}, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if !strings.Contains(res.NaturalReply, "QUERY_PROGRESS") && !strings.Contains(res.NaturalReply, "进展") { + t.Errorf("expected capability list: %s", res.NaturalReply) + } +} diff --git a/internal/coordinator/router.go b/internal/coordinator/router.go index 32840e61..3f707197 100644 --- a/internal/coordinator/router.go +++ b/internal/coordinator/router.go @@ -141,8 +141,10 @@ type RouteDecision struct { func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { // R0:确定性路由表——与既有意图分发完全等价(默认即现状) switch it { - case intent.IntentTriggerTask, intent.IntentAdvanceStage: - return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进指令,进入小队受控执行", Profile: profile} + case intent.IntentTriggerTask, intent.IntentAdvanceStage, intent.IntentTriggerWorkflow: + return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进/工作流运行指令,进入小队受控执行", Profile: profile} + case intent.IntentSwitchWorkflow: + return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "工作流切换需创建人确认,走模板与策略门禁", Profile: profile} case intent.IntentTriggerAdhocTask: return RouteDecision{Channel: ChannelSquadAdhoc, Confidence: 0.95, Reason: "自由任务升级确认,进入特设小队执行", Profile: profile} case intent.IntentSecurityForbidden, @@ -150,7 +152,9 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject, intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason, intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases, - intent.IntentQueryUserRights, intent.IntentSupplementInfo, intent.IntentDeclineExpectation: + intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus, + intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, + intent.IntentSupplementInfo, intent.IntentDeclineExpectation: return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "查询/治理/补充类意图,走规则模板与知识库直答", Profile: profile} } diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index 14b64bf0..2b096b9d 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -83,7 +83,8 @@ func isWriteIntent(t IntentType) bool { case IntentAdvanceStage, IntentTriggerTask, IntentTriggerAdhocTask, IntentSupplementInfo, IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, - IntentConfirmAction, IntentExecuteStageAdvance: + IntentConfirmAction, IntentExecuteStageAdvance, + IntentSwitchWorkflow, IntentTriggerWorkflow: return true default: return false @@ -94,7 +95,8 @@ func isWriteIntent(t IntentType) bool { func isQueryIntent(t IntentType) bool { switch t { case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, - IntentQueryUserRights, IntentQueryRejectReason: + IntentQueryUserRights, IntentQueryRejectReason, + IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities: return true default: return false diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 2e627051..9ca04f99 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -186,7 +186,37 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC candidates = append(candidates, *cand) } - // 领域 4: 进度与技术研判查询 (DECISION_QUERY - Progress & Technical) + // 领域 4: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判) + if cand := matchQueryWorkflowStatus(trimmed, userCtx); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + if cand := matchSwitchWorkflow(trimmed, userCtx); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + if cand := matchTriggerWorkflow(trimmed, userCtx); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + + // 领域 5: 文档/交付物状态只读查询 + if cand := matchQueryDocStatus(trimmed, userCtx); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + + // 领域 6: 帮助与暂不支持 + if cand := matchHelpCapabilities(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + if cand := matchUnsupportedActionCandidate(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + + // 领域 7: 进度与技术研判查询 (DECISION_QUERY - Progress & Technical) if cand := matchQueryProgress(trimmed); cand != nil { cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) @@ -208,6 +238,7 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC sortCandidates(candidates) top := &candidates[0] + applyCapabilityHint(top, userCtx) // ------------------------------------------------------------- // 阶段三 (Stage 3):槽位完备性与置信度裁决 (门禁裁决与追问下发) @@ -1064,6 +1095,9 @@ func matchSupplementInfo(input string) *CandidateIntent { } func matchQueryProgress(input string) *CandidateIntent { + if isWorkflowControlInput(input) { + return nil + } patterns := []string{ "测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难", "进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪", @@ -1135,7 +1169,7 @@ func matchTriggerTask(input string) *CandidateIntent { } func isGreetingOrHelp(input string) bool { - greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁", "帮助", "能做什么", "怎么用"} + greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"} for _, g := range greetings { if strings.Contains(strings.ToLower(input), g) { return true diff --git a/internal/intent/registry.go b/internal/intent/registry.go index 9e48854b..151622f5 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -167,6 +167,13 @@ func registerBuiltinActions(r *ActionRegistry) { {Key: "card.confirm", Domain: "conversation", Object: "system_ui", Goal: GoalDecide, Risk: RiskHigh, LegacyIntent: IntentConfirmAction, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActConfirm}}, {Key: "project.stage.execute", Domain: "project", Object: "project", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentExecuteStageAdvance, RequiresConfirm: true}, {Key: "security.forbidden", Domain: "security", Object: "system_configuration", Goal: GoalConverse, Risk: RiskHigh, LegacyIntent: IntentSecurityForbidden}, + + {Key: "workflow.switch", Domain: "project_workflow", Object: "workflow", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentSwitchWorkflow, CapabilityKey: "SWITCH_WORKFLOW", RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotTargetWorkflow, Required: true, Label: "目标工作流"}}, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "workflow.run", Domain: "project_workflow", Object: "workflow_run", Goal: GoalControl, Risk: RiskMedium, LegacyIntent: IntentTriggerWorkflow, CapabilityKey: "TRIGGER_WORKFLOW", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "workflow.status.query", Domain: "project_workflow", Object: "workflow", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryWorkflowStatus, CapabilityKey: "QUERY_WORKFLOW", DialogueActs: []DialogueAct{DialogueActAsk}}, + {Key: "document.status.query", Domain: "document_gov", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryDocStatus, CapabilityKey: "VIEW_DOC", DialogueActs: []DialogueAct{DialogueActAsk}}, + {Key: "conversation.help", Domain: "conversation", Object: "system_ui", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentHelpCapabilities, DialogueActs: []DialogueAct{DialogueActAsk}}, + {Key: "conversation.unsupported", Domain: "conversation", Object: "system_ui", Goal: GoalConverse, Risk: RiskLow, LegacyIntent: IntentUnsupportedAction}, } for _, s := range specs { _ = r.Register(s) diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go index 1f919d2a..8ac9d6c8 100644 --- a/internal/intent/registry_test.go +++ b/internal/intent/registry_test.go @@ -12,6 +12,8 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) { IntentDocGovProposalDecide, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden, + IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, + IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, } for _, lt := range legacyIntents { spec, ok := r.GetByLegacyIntent(lt) diff --git a/internal/intent/types.go b/internal/intent/types.go index 92becb6e..89178da3 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -36,7 +36,19 @@ const ( IntentExecuteStageAdvance IntentType = "EXECUTE_STAGE_ADVANCE" // 推进工作流阶段执行 // 安全与权限红线拦截 - IntentSecurityForbidden IntentType = "SECURITY_FORBIDDEN" + IntentSecurityForbidden IntentType = "SECURITY_FORBIDDEN" + + // 工作流控制(Phase 3 产品能力对齐) + IntentSwitchWorkflow IntentType = "SWITCH_WORKFLOW" + IntentTriggerWorkflow IntentType = "TRIGGER_WORKFLOW" + IntentQueryWorkflowStatus IntentType = "QUERY_WORKFLOW_STATUS" + + // 文档/运行态只读查询 + IntentQueryDocStatus IntentType = "QUERY_DOC_STATUS" + + // 对话修复:帮助 / 暂不支持 + IntentHelpCapabilities IntentType = "HELP_CAPABILITIES" + IntentUnsupportedAction IntentType = "UNSUPPORTED_ACTION" ) // ResolutionBranch 三分支意图消解类别 @@ -126,6 +138,9 @@ const ( // 路由层槽位 SlotTaskBrief = "task_brief" // 自由任务简报(升级建议携带的用户原始诉求) + + // 工作流槽位 + SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main ) // CardAction 交互卡片按钮动作 diff --git a/internal/intent/workflow_matcher.go b/internal/intent/workflow_matcher.go new file mode 100644 index 00000000..12c015ad --- /dev/null +++ b/internal/intent/workflow_matcher.go @@ -0,0 +1,263 @@ +package intent + +import ( + "fmt" + "strings" +) + +var workflowAliasMap = map[string]string{ + "产品经理": "product_manager", + "智能产品经理": "product_manager", + "产品工作流": "product_manager", + "产品经理工作流": "product_manager", + "urs": "product_manager", + "需求规格": "product_manager", + "测试经理": "test_manager", + "智能测试经理": "test_manager", + "测试工作流": "test_manager", + "测试经理工作流": "test_manager", + "验证方案": "test_manager", + "主工作流": "main", + "默认工作流": "main", + "main": "main", +} + +// extractWorkflowKey 从输入中解析工作流键 +func extractWorkflowKey(input string) string { + lower := strings.ToLower(input) + for phrase, key := range workflowAliasMap { + if strings.Contains(lower, strings.ToLower(phrase)) || strings.Contains(input, phrase) { + return key + } + } + return "" +} + +func isWorkflowControlInput(input string) bool { + if strings.Contains(input, "工作流") { + return true + } + return extractWorkflowKey(input) != "" +} + +func matchSwitchWorkflow(input string, snap *ContextSnapshot) *CandidateIntent { + act := detectDialogueAct(input) + if act == DialogueActAsk { + return nil + } + if !isWorkflowControlInput(input) { + return nil + } + switchMarkers := []string{"切换", "换成", "改用", "激活", "切到", "切换到", "切换至"} + hasSwitch := false + for _, m := range switchMarkers { + if strings.Contains(input, m) { + hasSwitch = true + break + } + } + if !hasSwitch { + return nil + } + wfKey := extractWorkflowKey(input) + slots := map[string]string{} + if wfKey != "" { + slots[SlotTargetWorkflow] = wfKey + } + reasoning := "用户请求切换项目激活工作流" + if wfKey == "" { + return &CandidateIntent{ + Intent: IntentSwitchWorkflow, + Confidence: 0.88, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: "请说明要切换到哪个工作流:产品经理(product_manager)、测试经理(test_manager)还是主工作流(main)?", + } + } + return &CandidateIntent{ + Intent: IntentSwitchWorkflow, + Confidence: 0.93, + FilledSlots: slots, + Reasoning: reasoning + " → " + wfKey, + } +} + +func matchTriggerWorkflow(input string, snap *ContextSnapshot) *CandidateIntent { + if detectDialogueAct(input) == DialogueActAsk { + return nil + } + if !isWorkflowControlInput(input) { + return nil + } + runMarkers := []string{"运行", "启动", "执行", "跑一下", "跑一版", "开始跑"} + hasRun := false + for _, m := range runMarkers { + if strings.Contains(input, m) { + hasRun = true + break + } + } + wfKey := extractWorkflowKey(input) + if !hasRun && wfKey == "" { + return nil + } + if !hasRun && wfKey != "" { + // 「生成 URS」类可能走 TRIGGER_TASK,不强行 workflow run + if strings.Contains(input, "切换") { + return nil + } + } + slots := map[string]string{} + if wfKey != "" { + slots[SlotTargetWorkflow] = wfKey + } else if snap != nil && snap.AvailableActions != nil { + // 默认当前上下文工作流由 L2 解析 + for _, a := range snap.AvailableActions { + if strings.HasPrefix(a, "TRIGGER_WORKFLOW:") { + slots[SlotTargetWorkflow] = strings.TrimPrefix(a, "TRIGGER_WORKFLOW:") + break + } + } + } + if wfKey == "" && slots[SlotTargetWorkflow] == "" { + return &CandidateIntent{ + Intent: IntentTriggerWorkflow, + Confidence: 0.86, + FilledSlots: slots, + Reasoning: "用户请求运行工作流但未指定目标", + ClarificationQuestion: "请说明要运行哪个工作流:产品经理(product_manager)还是测试经理(test_manager)?", + } + } + return &CandidateIntent{ + Intent: IntentTriggerWorkflow, + Confidence: 0.91, + FilledSlots: slots, + Reasoning: "用户请求运行/启动项目工作流", + } +} + +func matchQueryWorkflowStatus(input string, snap *ContextSnapshot) *CandidateIntent { + if detectDialogueAct(input) != DialogueActAsk { + return nil + } + markers := []string{ + "当前工作流", "哪个工作流", "什么工作流", "激活的工作流", "激活的是哪个", + "有哪些工作流", "可用工作流", "工作流列表", "工作流状态", + } + hasWF := strings.Contains(input, "工作流") + matched := false + for _, m := range markers { + if strings.Contains(input, m) { + matched = true + break + } + } + if !matched && !(hasWF && (strings.Contains(input, "吗") || strings.Contains(input, "?") || strings.Contains(input, "?"))) { + return nil + } + return &CandidateIntent{ + Intent: IntentQueryWorkflowStatus, + Confidence: 0.92, + FilledSlots: map[string]string{ + SlotQueryTopic: "workflow_status", + }, + Reasoning: "用户查询当前或可用工作流状态", + } +} + +func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent { + if detectDialogueAct(input) != DialogueActAsk { + return nil + } + // 已由 DOCGOV 状态查询覆盖的认领/送审不重复 + if strings.Contains(input, "认领") || strings.Contains(input, "送审") { + return nil + } + markers := []string{ + "文档状态", "审核状态", "什么状态", "当前状态", "版本状态", + "血缘", "stale", "过期原因", "完成了吗", "生成完了吗", + } + matched := false + for _, m := range markers { + if strings.Contains(input, m) { + matched = true + break + } + } + if !matched { + return nil + } + slots := map[string]string{SlotQueryTopic: "doc_status"} + if snap != nil && snap.ActiveDocID != "" { + slots[SlotTargetDoc] = snap.ActiveDocID + } + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.90, + FilledSlots: slots, + Reasoning: "用户查询受控文档或交付物状态/血缘(只读)", + } +} + +func matchHelpCapabilities(input string) *CandidateIntent { + helpMarkers := []string{ + "能做什么", "可以做什么", "有什么功能", "怎么用", "如何使用", + "帮助", "使用说明", "能力清单", "支持哪些", "你会什么", + } + for _, m := range helpMarkers { + if strings.Contains(input, m) { + return &CandidateIntent{ + Intent: IntentHelpCapabilities, + Confidence: 0.94, + FilledSlots: map[string]string{ + SlotQueryTopic: "help", + }, + Reasoning: "用户询问系统当前可执行能力", + } + } + } + return nil +} + +func matchUnsupportedActionCandidate(input string) *CandidateIntent { + unsupportedMarkers := []string{ + "下载回复", "下载下来", "复制回复", "保存为私密文档", "共享到项目", "取消共享", + "删除私密文档", "批量导出", "修改正文", "直接改文档正文", + } + for _, m := range unsupportedMarkers { + if strings.Contains(input, m) { + return &CandidateIntent{ + Intent: IntentUnsupportedAction, + Confidence: 0.93, + FilledSlots: map[string]string{ + SlotQueryTopic: "unsupported", + }, + Reasoning: "用户请求的能力当前仅支持显式 UI 操作,不支持自然语言自动执行", + } + } + } + return nil +} + +// applyCapabilityHint 若上下文宣告了 AvailableActions,为已识别动作附加证据(不篡改语义) +func applyCapabilityHint(top *CandidateIntent, snap *ContextSnapshot) { + if top == nil || snap == nil || len(snap.AvailableActions) == 0 { + return + } + spec, ok := DefaultRegistry().GetByLegacyIntent(top.Intent) + if !ok || spec.CapabilityKey == "" { + return + } + capKey := spec.CapabilityKey + if top.Intent == IntentTriggerWorkflow { + if wf := top.FilledSlots[SlotTargetWorkflow]; wf != "" { + capKey = "TRIGGER_WORKFLOW:" + wf + } + } + for _, a := range snap.AvailableActions { + if a == capKey || strings.HasPrefix(a, capKey) { + return + } + } + top.Reasoning += fmt.Sprintf(";注意:当前上下文 AvailableActions 未包含 %s(L2 将做权限/能力裁决)", capKey) +} diff --git a/internal/intent/workflow_matcher_test.go b/internal/intent/workflow_matcher_test.go new file mode 100644 index 00000000..bf8e0ab4 --- /dev/null +++ b/internal/intent/workflow_matcher_test.go @@ -0,0 +1,79 @@ +package intent_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/intent" +) + +func TestWorkflowMatcher_SwitchProductManager(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + AvailableActions: []string{"SWITCH_WORKFLOW", "QUERY_WORKFLOW"}, + } + res, err := rec.Recognize(context.Background(), "切换到智能测试经理工作流", ctx) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentSwitchWorkflow { + t.Fatalf("expected SWITCH_WORKFLOW, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotTargetWorkflow] != "test_manager" { + t.Errorf("expected test_manager, got %s", res.TopIntent.FilledSlots[intent.SlotTargetWorkflow]) + } + if res.IntentFrameV2 == nil || res.IntentFrameV2.ActionKey != "workflow.switch" { + t.Errorf("expected v2 action workflow.switch, got %+v", res.IntentFrameV2) + } +} + +func TestWorkflowMatcher_QueryStatus(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "当前激活的是哪个工作流?", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentQueryWorkflowStatus { + t.Fatalf("expected QUERY_WORKFLOW_STATUS, got %s", res.TopIntent.Intent) + } +} + +func TestWorkflowMatcher_HelpNotCasualChat(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "这个系统能做什么?", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentHelpCapabilities { + t.Fatalf("expected HELP_CAPABILITIES, got %s", res.TopIntent.Intent) + } +} + +func TestWorkflowMatcher_UnsupportedAction(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "帮我把这条回复下载下来", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentUnsupportedAction { + t.Fatalf("expected UNSUPPORTED_ACTION, got %s", res.TopIntent.Intent) + } +} + +func TestWorkflowMatcher_NonCreatorCapabilityHint(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + AvailableActions: []string{"QUERY_PROGRESS"}, + } + res, err := rec.Recognize(context.Background(), "切换到产品经理工作流", ctx) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentSwitchWorkflow { + t.Fatalf("expected semantic SWITCH_WORKFLOW, got %s", res.TopIntent.Intent) + } + if res.TopIntent.Reasoning == "" || !strings.Contains(res.TopIntent.Reasoning, "AvailableActions") { + t.Errorf("expected capability hint in reasoning, got %q", res.TopIntent.Reasoning) + } +}