From 9cb129d3e83e0b6d330e0e6d22d83b8d93a6fc16 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 11:56:24 +0800 Subject: [PATCH] feat(intent): Phase 4 run control, subtypes, and limited IntentPlan Add RUN_STATUS_QUERY/RUN_RESUME/RUN_RESTART with matcher and coordinator handlers; write QUERY_PROGRESS/SUPPLEMENT_INFO subtypes to V2 frame; support safe query+query multi-intent plans via IntentPlan dual-write. Co-authored-by: Cursor --- internal/coordinator/engine.go | 9 +++ internal/coordinator/engine_run.go | 98 ++++++++++++++++++++++++ internal/coordinator/engine_run_test.go | 59 +++++++++++++++ internal/coordinator/router.go | 7 +- internal/intent/compatibility.go | 26 ++++++- internal/intent/dialogue_act.go | 6 +- internal/intent/plan_builder.go | 99 +++++++++++++++++++++++++ internal/intent/plan_builder_test.go | 64 ++++++++++++++++ internal/intent/recognizer.go | 14 +++- internal/intent/registry.go | 4 + internal/intent/registry_test.go | 1 + internal/intent/run_matcher.go | 79 ++++++++++++++++++++ internal/intent/types.go | 11 +++ internal/intent/types_v2.go | 16 ++++ 14 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 internal/coordinator/engine_run.go create mode 100644 internal/coordinator/engine_run_test.go create mode 100644 internal/intent/plan_builder.go create mode 100644 internal/intent/plan_builder_test.go create mode 100644 internal/intent/run_matcher.go diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 9f571a4c..165e7555 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -205,6 +205,15 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentUnsupportedAction: coordRes, coordErr = e.handleUnsupportedAction(ctx, top, userCtx) + case intent.IntentRunStatusQuery: + coordRes, coordErr = e.handleRunStatusQuery(ctx, top, userCtx) + + case intent.IntentRunResume: + coordRes, coordErr = e.handleRunResume(ctx, top, userCtx) + + case intent.IntentRunRestart: + coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx) + default: nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) coordRes = &CoordinationResult{ diff --git a/internal/coordinator/engine_run.go b/internal/coordinator/engine_run.go new file mode 100644 index 00000000..4e4ae1ed --- /dev/null +++ b/internal/coordinator/engine_run.go @@ -0,0 +1,98 @@ +package coordinator + +import ( + "context" + "fmt" + + "chat-agent-system/internal/intent" +) + +func (e *CoordinatorEngine) handleRunStatusQuery(_ context.Context, _ *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + workDir := userCtx.WorkDir + reply := "📊 **运行状态查询(只读)**\n\n" + if workDir != "" { + reply += fmt.Sprintf("• 最近工作区:`%s`\n• 您可以在任务面板查看该运行的阶段、失败原因与产物摘要。\n", workDir) + } else { + reply += "• 当前会话未绑定具体工作区。请指定要查询的运行任务,或先在界面打开失败任务详情。\n" + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentRunStatusQuery, + NaturalReply: reply, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleRunResume(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + workDir := cand.FilledSlots[intent.SlotWorkDir] + if workDir == "" { + workDir = userCtx.WorkDir + } + if workDir == "" { + msg := cand.ClarificationQuestion + if msg == "" { + msg = "续跑需要绑定目标工作区。请说明要恢复哪一次失败运行,或在工作区面板选择「继续」。" + } + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: intent.IntentRunResume, + ClarificationPrompt: msg, + NaturalReply: msg, + UpdatedContext: userCtx, + }, nil + } + + msg := fmt.Sprintf( + "已理解您要续跑工作区 `%s`(复用已有上下文)。请在任务面板确认「继续」,或回复「确认续跑」后我将为您发起 resume 请求。", + workDir, + ) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: intent.IntentRunResume, + NaturalReply: msg, + ClarificationPrompt: msg, + UpdatedContext: userCtx, + NextExpectation: &intent.PendingExpectation{ + ExpectedIntent: intent.IntentRunResume, + TargetAction: "run.resume", + Prompt: "确认续跑长任务", + AllowAffirmative: true, + Payload: map[string]string{ + intent.SlotWorkDir: workDir, + }, + DefaultOption: &intent.ExpectationOption{ + OptionID: "confirm_resume", + Title: "确认续跑", + Intent: intent.IntentRunResume, + Slots: map[string]string{ + intent.SlotWorkDir: workDir, + }, + }, + }, + }, nil +} + +func (e *CoordinatorEngine) handleRunRestart(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + msg := "已理解您希望重新运行任务(将创建新工作区,保留原始意图但不复用失败现场)。这是高风险操作,请确认后再执行。" + if cand.ClarificationQuestion != "" { + msg = cand.ClarificationQuestion + } + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: intent.IntentRunRestart, + NaturalReply: msg, + ClarificationPrompt: msg, + UpdatedContext: userCtx, + NextExpectation: &intent.PendingExpectation{ + ExpectedIntent: intent.IntentRunRestart, + TargetAction: "run.restart", + Prompt: "确认重新运行长任务", + AllowAffirmative: false, + DefaultOption: &intent.ExpectationOption{ + OptionID: "confirm_restart", + Title: "确认重新运行", + Intent: intent.IntentRunRestart, + }, + }, + }, nil +} diff --git a/internal/coordinator/engine_run_test.go b/internal/coordinator/engine_run_test.go new file mode 100644 index 00000000..fa1e95d6 --- /dev/null +++ b/internal/coordinator/engine_run_test.go @@ -0,0 +1,59 @@ +package coordinator_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/intent" +) + +func TestEngine_RunStatusQuery_WithWorkDir(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: "项目"}, + WorkDir: "/data/dev/ws-123", + 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, "ws-123") { + t.Errorf("expected workdir in reply: %s", res.NaturalReply) + } +} + +func TestEngine_RunResume_MissingWorkDirClarify(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: "项目"}, + BlackboardFacts: map[string]string{}, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.Status != coordinator.StatusClarificationRequired { + t.Fatalf("expected CLARIFICATION_REQUIRED, got %s", res.Status) + } +} diff --git a/internal/coordinator/router.go b/internal/coordinator/router.go index 3f707197..ce0eda7e 100644 --- a/internal/coordinator/router.go +++ b/internal/coordinator/router.go @@ -141,8 +141,9 @@ type RouteDecision struct { func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { // R0:确定性路由表——与既有意图分发完全等价(默认即现状) switch it { - case intent.IntentTriggerTask, intent.IntentAdvanceStage, intent.IntentTriggerWorkflow: - return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进/工作流运行指令,进入小队受控执行", Profile: profile} + case intent.IntentTriggerTask, intent.IntentAdvanceStage, intent.IntentTriggerWorkflow, + intent.IntentRunResume, intent.IntentRunRestart: + 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: @@ -153,7 +154,7 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason, intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases, intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus, - intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, + intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery, intent.IntentSupplementInfo, intent.IntentDeclineExpectation: return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "查询/治理/补充类意图,走规则模板与知识库直答", Profile: profile} } diff --git a/internal/intent/compatibility.go b/internal/intent/compatibility.go index 756e2e5d..264f62f9 100644 --- a/internal/intent/compatibility.go +++ b/internal/intent/compatibility.go @@ -3,6 +3,21 @@ package intent import "strings" // branchToRelation 将三分支映射为 ExpectationRelation +func mapQueryProgressSubtype(topic string) string { + switch topic { + case "risk": + return "risk" + case "testing_analysis", "technical_design": + return "technical" + case "talk_script": + return "communication" + case "progress": + return "status" + default: + return topic + } +} + func branchToRelation(branch ResolutionBranch, cand *CandidateIntent) ExpectationRelation { switch branch { case BranchExpectationHit: @@ -60,8 +75,15 @@ func BuildIntentFrameV2(res *IntentRecognitionResult, registry *ActionRegistry) } } if top.FilledSlots != nil { - if topic, ok := top.FilledSlots[SlotQueryTopic]; ok && top.Intent == IntentQueryProgress { - frame.Subtype = topic + switch top.Intent { + case IntentQueryProgress: + if topic, ok := top.FilledSlots[SlotQueryTopic]; ok { + frame.Subtype = mapQueryProgressSubtype(topic) + } + case IntentSupplementInfo: + if action, ok := top.FilledSlots[SlotSupplementAction]; ok { + frame.Subtype = action + } } } frame.Evidence = buildEvidenceFromCandidate(top, res) diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index 2b096b9d..8e43f104 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -84,7 +84,8 @@ func isWriteIntent(t IntentType) bool { IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, IntentConfirmAction, IntentExecuteStageAdvance, - IntentSwitchWorkflow, IntentTriggerWorkflow: + IntentSwitchWorkflow, IntentTriggerWorkflow, + IntentRunResume, IntentRunRestart: return true default: return false @@ -96,7 +97,8 @@ func isQueryIntent(t IntentType) bool { switch t { case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, - IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities: + IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, + IntentRunStatusQuery: return true default: return false diff --git a/internal/intent/plan_builder.go b/internal/intent/plan_builder.go new file mode 100644 index 00000000..62f60947 --- /dev/null +++ b/internal/intent/plan_builder.go @@ -0,0 +1,99 @@ +package intent + +import ( + "context" + "fmt" + "strings" +) + +var planConnectors = []string{",然后", ",然后", ",并", ",并", ",接着", ",接着", ";再", ";再"} + +// splitOnPlanConnectors 按有限连接词拆分多意图输入 +func splitOnPlanConnectors(input string) []string { + for _, sep := range planConnectors { + if idx := strings.Index(input, sep); idx > 0 { + left := strings.TrimSpace(input[:idx]) + right := strings.TrimSpace(input[idx+len(sep):]) + if left != "" && right != "" { + return []string{left, right} + } + } + } + return nil +} + +// buildLimitedIntentPlan 构建有限多意图计划(仅 query+query / query+generate 等安全组合) +func buildLimitedIntentPlan(ctx context.Context, r *RuleBasedRecognizer, input string, snap *ContextSnapshot, primary *IntentRecognitionResult) *IntentPlan { + parts := splitOnPlanConnectors(input) + if len(parts) < 2 { + return nil + } + + var steps []IntentPlanStep + for i, part := range parts { + subRes, err := r.Recognize(ctx, part, snap) + if err != nil || subRes == nil || subRes.TopIntent == nil { + return nil + } + frame := BuildIntentFrameV2(subRes, DefaultRegistry()) + if frame == nil { + return nil + } + // 高风险写动作组合初期不支持 + if isWriteIntent(subRes.TopIntent.Intent) { + return nil + } + stepID := fmt.Sprintf("%d", i+1) + var depends []string + if i > 0 { + depends = []string{fmt.Sprintf("%d", i)} + } + steps = append(steps, IntentPlanStep{ + StepID: stepID, + Frame: *frame, + DependsOn: depends, + OnFailure: "abort", + }) + } + + if len(steps) < 2 { + return nil + } + // 至少一个查询步骤 + hasQuery := false + for _, s := range steps { + if s.Frame.Goal == GoalQuery || isQueryIntent(s.Frame.LegacyIntent) { + hasQuery = true + break + } + } + if !hasQuery { + return nil + } + + if primary != nil && primary.IntentFrameV2 != nil { + primary.IntentFrameV2.Relation = RelationExpectationPlusNew + } + return &IntentPlan{ + Steps: steps, + Atomicity: "sequential", + NeedsConfirm: false, + FailureMode: "stop_on_first_failure", + } +} + +func inferSupplementSubtype(input string, slots map[string]string) string { + if strings.Contains(input, "附件") || strings.Contains(input, "材料") || strings.Contains(input, "上传") { + return "attach" + } + if strings.Contains(input, "纠正") || strings.Contains(input, "更正") || + (strings.Contains(input, "改为") && strings.Contains(input, "原来")) { + return "correct" + } + if strings.Contains(input, "修改为") || strings.Contains(input, "改为") || strings.Contains(input, "改成") { + if slots[SlotFactKey] != "" { + return "correct" + } + } + return "add" +} diff --git a/internal/intent/plan_builder_test.go b/internal/intent/plan_builder_test.go new file mode 100644 index 00000000..ae8c55f5 --- /dev/null +++ b/internal/intent/plan_builder_test.go @@ -0,0 +1,64 @@ +package intent_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/intent" +) + +func TestLimitedIntentPlan_QueryThenQuery(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "查一下项目风险,然后查相关法规", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.IntentPlan == nil { + t.Fatal("expected intent_plan for query+query combination") + } + if len(res.IntentPlan.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(res.IntentPlan.Steps)) + } + if res.IntentPlan.Steps[0].Frame.Goal != intent.GoalQuery { + t.Errorf("step1 goal want query got %s", res.IntentPlan.Steps[0].Frame.Goal) + } + if res.IntentPlan.Steps[1].Frame.Goal != intent.GoalQuery { + t.Errorf("step2 goal want query got %s", res.IntentPlan.Steps[1].Frame.Goal) + } +} + +func TestSupplementSubtype_InV2Frame(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "客户反馈附件已上传,请入库", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.IntentFrameV2 == nil || res.IntentFrameV2.Subtype != "attach" { + t.Errorf("expected subtype attach, got %+v", res.IntentFrameV2) + } +} + +func TestRunMatcher_ResumeRequiresClarification(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.IntentRunResume { + t.Fatalf("expected RUN_RESUME, got %s", res.TopIntent.Intent) + } + if !res.RequiresClarifying && res.TopIntent.ClarificationQuestion == "" { + t.Error("expected clarification when work_dir missing") + } +} + +func TestRunMatcher_StatusQuery(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.IntentRunStatusQuery { + t.Fatalf("expected RUN_STATUS_QUERY, got %s", res.TopIntent.Intent) + } +} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 9ca04f99..3ff3a72a 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -186,7 +186,13 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC candidates = append(candidates, *cand) } - // 领域 4: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判) + // 领域 4: 长任务运行控制 (Phase 4) + if cand := matchRunControl(trimmed, userCtx); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } + + // 领域 5: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判) if cand := matchQueryWorkflowStatus(trimmed, userCtx); cand != nil { cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) @@ -291,6 +297,9 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC RequiresClarifying: requiresClarifying, } finalizeRecognitionResult(result) + if plan := buildLimitedIntentPlan(ctx, r, trimmed, userCtx, result); plan != nil { + result.IntentPlan = plan + } return result, nil } @@ -1085,6 +1094,7 @@ func matchSupplementInfo(input string) *CandidateIntent { slots = make(map[string]string) } slots[SlotSupplementDoc] = input + slots[SlotSupplementAction] = inferSupplementSubtype(input, slots) return &CandidateIntent{ Intent: IntentSupplementInfo, @@ -1095,7 +1105,7 @@ func matchSupplementInfo(input string) *CandidateIntent { } func matchQueryProgress(input string) *CandidateIntent { - if isWorkflowControlInput(input) { + if isWorkflowControlInput(input) || isRunControlInput(input) { return nil } patterns := []string{ diff --git a/internal/intent/registry.go b/internal/intent/registry.go index 151622f5..7781376d 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -174,6 +174,10 @@ func registerBuiltinActions(r *ActionRegistry) { {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}, + + {Key: "run.status.query", Domain: "project", Object: "workflow_run", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentRunStatusQuery, DialogueActs: []DialogueAct{DialogueActAsk}}, + {Key: "run.resume", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskMedium, LegacyIntent: IntentRunResume, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "run.restart", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentRunRestart, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, } for _, s := range specs { _ = r.Register(s) diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go index 8ac9d6c8..151e2ed3 100644 --- a/internal/intent/registry_test.go +++ b/internal/intent/registry_test.go @@ -14,6 +14,7 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) { IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, + IntentRunStatusQuery, IntentRunResume, IntentRunRestart, } for _, lt := range legacyIntents { spec, ok := r.GetByLegacyIntent(lt) diff --git a/internal/intent/run_matcher.go b/internal/intent/run_matcher.go new file mode 100644 index 00000000..dc568903 --- /dev/null +++ b/internal/intent/run_matcher.go @@ -0,0 +1,79 @@ +package intent + +import "strings" + +func isRunControlInput(input string) bool { + runMarkers := []string{ + "继续", "续跑", "接着跑", "继续上次", "继续任务", "恢复运行", "恢复任务", + "重来", "重新运行", "重启", "从头开始", "重新跑", + "运行状态", "任务状态", "执行状态", "跑到哪", "失败了吗", "完成了吗", "任务进度", + "上次任务", "这次运行", "工作区", + } + for _, m := range runMarkers { + if strings.Contains(input, m) { + return true + } + } + return false +} + +func matchRunControl(input string, snap *ContextSnapshot) *CandidateIntent { + if !isRunControlInput(input) { + return nil + } + // 避免与挂起预期裸肯定词冲突(「继续」单独出现且 pending 存在时由 expectation 处理) + if strings.TrimSpace(input) == "继续" || strings.TrimSpace(input) == "接着" { + return nil + } + + slots := map[string]string{} + if snap != nil && snap.SnapshotID != "" { + _ = snap.SnapshotID + } + + restartMarkers := []string{"重来", "重新运行", "重启", "从头开始", "重新跑", "重新执行"} + for _, m := range restartMarkers { + if strings.Contains(input, m) { + slots[SlotRunAction] = "restart" + return &CandidateIntent{ + Intent: IntentRunRestart, + Confidence: 0.91, + FilledSlots: slots, + Reasoning: "用户请求重新启动长任务运行(将创建新工作区,保留原意图)", + } + } + } + + resumeMarkers := []string{"继续", "续跑", "接着跑", "继续上次", "继续任务", "恢复运行", "恢复任务"} + for _, m := range resumeMarkers { + if strings.Contains(input, m) { + slots[SlotRunAction] = "resume" + cand := &CandidateIntent{ + Intent: IntentRunResume, + Confidence: 0.90, + FilledSlots: slots, + Reasoning: "用户请求续跑/恢复已有长任务工作区", + } + if snap == nil || (len(slots[SlotWorkDir]) == 0) { + cand.ClarificationQuestion = "请说明要续跑的具体任务或失败阶段;若您指的是最近一次失败运行,请确认后我将基于该工作区继续。" + } + return cand + } + } + + if detectDialogueAct(input) == DialogueActAsk || + strings.Contains(input, "状态") || strings.Contains(input, "失败了吗") || + strings.Contains(input, "完成了吗") || strings.Contains(input, "跑到哪") { + slots[SlotRunAction] = "status" + return &CandidateIntent{ + Intent: IntentRunStatusQuery, + Confidence: 0.89, + FilledSlots: map[string]string{ + SlotRunAction: "status", + SlotQueryTopic: "run_status", + }, + Reasoning: "用户查询长任务/工作流运行状态(只读)", + } + } + return nil +} diff --git a/internal/intent/types.go b/internal/intent/types.go index 89178da3..d5d358b3 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -49,6 +49,11 @@ const ( // 对话修复:帮助 / 暂不支持 IntentHelpCapabilities IntentType = "HELP_CAPABILITIES" IntentUnsupportedAction IntentType = "UNSUPPORTED_ACTION" + + // 长任务运行控制(Phase 4) + IntentRunStatusQuery IntentType = "RUN_STATUS_QUERY" + IntentRunResume IntentType = "RUN_RESUME" + IntentRunRestart IntentType = "RUN_RESTART" ) // ResolutionBranch 三分支意图消解类别 @@ -118,6 +123,7 @@ type IntentRecognitionResult struct { // V2 双写:迁移期与 TopIntent 并存,Router/Coordinator 默认仍消费 TopIntent。 IntentFrameV2 *IntentFrameV2 `json:"intent_frame_v2,omitempty"` RankedFrames *RankedIntentSet `json:"ranked_frames,omitempty"` + IntentPlan *IntentPlan `json:"intent_plan,omitempty"` } // 常见槽位 Key 常量定义 @@ -141,6 +147,11 @@ const ( // 工作流槽位 SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main + + // 长任务/运行槽位 + SlotWorkDir = "work_dir" + SlotRunAction = "run_action" // status | resume | restart + SlotSupplementAction = "supplement_action" // add | correct | attach ) // CardAction 交互卡片按钮动作 diff --git a/internal/intent/types_v2.go b/internal/intent/types_v2.go index 4f29fedf..12cd2272 100644 --- a/internal/intent/types_v2.go +++ b/internal/intent/types_v2.go @@ -75,3 +75,19 @@ type CandidateConflict struct { } const IntentFrameV2Version = "v2.0" + +// IntentPlan 有限多意图执行计划(Phase 4:仅支持可验证组合) +type IntentPlan struct { + Steps []IntentPlanStep `json:"steps"` + Atomicity string `json:"atomicity"` // sequential + NeedsConfirm bool `json:"needs_confirm,omitempty"` + FailureMode string `json:"failure_mode,omitempty"` // stop_on_first_failure +} + +// IntentPlanStep 计划步骤 +type IntentPlanStep struct { + StepID string `json:"step_id"` + Frame IntentFrameV2 `json:"frame"` + DependsOn []string `json:"depends_on,omitempty"` + OnFailure string `json:"on_failure,omitempty"` // abort | skip +}