feat(intent): Phase 3 workflow/help intents and coordinator handlers

Add SWITCH/TRIGGER/QUERY workflow, doc status, help and unsupported intents
with ActionSpec registry entries, NL matchers, L2 handlers with creator-only
switch gate, router routes, and capability-aware AvailableActions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-11 11:54:24 +08:00
co-authored by Cursor
parent cbe9f8c43a
commit e3c62fbc57
12 changed files with 808 additions and 11 deletions
+23 -3
View File
@@ -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
}
+18
View File
@@ -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{
+248
View File
@@ -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),
},
}
}
@@ -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)
}
}
+7 -3
View File
@@ -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}
}
+4 -2
View File
@@ -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
+36 -2
View File
@@ -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
+7
View File
@@ -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)
+2
View File
@@ -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)
+16 -1
View File
@@ -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 交互卡片按钮动作
+263
View File
@@ -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)
}
+79
View File
@@ -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)
}
}