From 03021d5f691d6c92bcf5c797044ad751163efe65 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Mon, 13 Apr 2026 17:23:05 +0800 Subject: [PATCH] feat: refactor skill matching and execution services, introducing RuntimeService for improved structure --- internal/ai/skills/candidate_loader.go | 34 +++++++++ internal/ai/skills/log_test.go | 90 ++++++++++++++++++++++++ internal/ai/skills/matcher.go | 24 +------ internal/ai/skills/runtime.go | 73 +------------------ internal/ai/skills/service.go | 97 ++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 93 deletions(-) create mode 100644 internal/ai/skills/candidate_loader.go create mode 100644 internal/ai/skills/log_test.go create mode 100644 internal/ai/skills/service.go diff --git a/internal/ai/skills/candidate_loader.go b/internal/ai/skills/candidate_loader.go new file mode 100644 index 0000000..64394aa --- /dev/null +++ b/internal/ai/skills/candidate_loader.go @@ -0,0 +1,34 @@ +package skills + +import ( + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/utils" + "cs-agent/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +func findManualSkillDefinition(skillCode string) *models.SkillDefinition { + return repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), skillCode) +} + +func loadCandidateSkills(aiAgent *models.AIAgent) []models.SkillDefinition { + if aiAgent == nil { + return nil + } + skillIDs := utils.SplitInt64s(aiAgent.SkillIDs) + if len(skillIDs) == 0 { + return nil + } + ret := make([]models.SkillDefinition, 0, len(skillIDs)) + for _, id := range skillIDs { + // TODO 这里批量查询一下,批量查询返回数据的顺序需要保证和skillIDs一致 + skill := repositories.SkillDefinitionRepository.Get(sqls.DB(), id) + if skill == nil || skill.Status != enums.StatusOk { + continue + } + ret = append(ret, *skill) + } + return ret +} diff --git a/internal/ai/skills/log_test.go b/internal/ai/skills/log_test.go new file mode 100644 index 0000000..bfa77f4 --- /dev/null +++ b/internal/ai/skills/log_test.go @@ -0,0 +1,90 @@ +package skills + +import ( + "strings" + "testing" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" +) + +func TestBuildRunLogMatchedPlan(t *testing.T) { + log := BuildRunLog( + RuntimeContext{ + ConversationID: 11, + AIAgentID: 22, + ManualSkillCode: "manual_refund", + IntentCode: "refund", + UserMessage: "我要退款", + }, + &ExecutionPlan{ + AIAgent: &models.AIAgent{ID: 22}, + AIConfig: &models.AIConfig{ + ID: 33, + ModelName: "gpt-test", + Provider: enums.AIProviderOpenAI, + }, + Skill: &models.SkillDefinition{ + ID: 44, + Code: "refund_skill", + }, + MatchReason: "llm_route", + }, + &ExecutionTrace{Status: "ok"}, + nil, + ) + + if log == nil { + t.Fatalf("expected run log") + } + if log.ConversationID != 11 || log.AIAgentID != 22 || log.AIConfigID != 33 { + t.Fatalf("unexpected ids in run log: %#v", log) + } + if !log.Matched || !log.FinalSelected || log.SkillCode != "refund_skill" { + t.Fatalf("expected matched skill log, got %#v", log) + } + if log.MatchReason != "llm_route" { + t.Fatalf("unexpected match reason: %q", log.MatchReason) + } + if !strings.Contains(log.TraceData, `"status":"ok"`) { + t.Fatalf("expected trace data to contain status, got %q", log.TraceData) + } +} + +func TestBuildRunLogNotMatchedAndError(t *testing.T) { + log := BuildRunLog( + RuntimeContext{ + AIAgentID: 22, + UserMessage: "随便问问", + }, + nil, + &ExecutionTrace{Status: "route_error"}, + assertErr("route failed"), + ) + + if log == nil { + t.Fatalf("expected run log") + } + if log.Matched { + t.Fatalf("expected unmatched log") + } + if log.ErrorMessage != "route failed" { + t.Fatalf("unexpected error message: %q", log.ErrorMessage) + } + + noMatchLog := BuildRunLog( + RuntimeContext{AIAgentID: 22, UserMessage: "随便问问"}, + &ExecutionPlan{MatchReason: ""}, + &ExecutionTrace{Status: "not_matched"}, + nil, + ) + if noMatchLog.MatchReason != "not_matched" { + t.Fatalf("expected default not_matched reason, got %q", noMatchLog.MatchReason) + } +} + +type assertErr string + +func (e assertErr) Error() string { + return string(e) +} diff --git a/internal/ai/skills/matcher.go b/internal/ai/skills/matcher.go index 04e6c39..b1fb9af 100644 --- a/internal/ai/skills/matcher.go +++ b/internal/ai/skills/matcher.go @@ -7,11 +7,8 @@ import ( "cs-agent/internal/models" "cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/errorsx" - "cs-agent/internal/pkg/utils" - "cs-agent/internal/repositories" "github.com/mlogclub/simple/common/strs" - "github.com/mlogclub/simple/sqls" ) type intentTriggerConfig struct { @@ -21,7 +18,7 @@ type intentTriggerConfig struct { // MatchSkill 对单个 SkillDefinition 执行命中判断。 func MatchSkill(execCtx context.Context, ctx RuntimeContext, aiAgent *models.AIAgent, aiConfig *models.AIConfig) (*models.SkillDefinition, string, *RouteTrace, error) { if strs.IsNotBlank(ctx.ManualSkillCode) { - skill := repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), ctx.ManualSkillCode) + skill := findManualSkillDefinition(ctx.ManualSkillCode) if skill == nil || skill.Status != enums.StatusOk { return nil, "", nil, errorsx.InvalidParam("Skill 不存在或未启用") } @@ -83,22 +80,3 @@ func MatchSkill(execCtx context.Context, ctx RuntimeContext, aiAgent *models.AIA } return selected, "llm_route", trace, nil } - -func loadCandidateSkills(aiAgent *models.AIAgent) []models.SkillDefinition { - if aiAgent == nil { - return nil - } - skillIDs := utils.SplitInt64s(aiAgent.SkillIDs) - if len(skillIDs) == 0 { - return nil - } - ret := make([]models.SkillDefinition, 0, len(skillIDs)) - for _, id := range skillIDs { - skill := repositories.SkillDefinitionRepository.Get(sqls.DB(), id) - if skill == nil || skill.Status != enums.StatusOk { - continue - } - ret = append(ret, *skill) - } - return ret -} diff --git a/internal/ai/skills/runtime.go b/internal/ai/skills/runtime.go index 89c020a..c7a1356 100644 --- a/internal/ai/skills/runtime.go +++ b/internal/ai/skills/runtime.go @@ -2,88 +2,21 @@ package skills import ( "context" - "strings" "cs-agent/internal/models" - "cs-agent/internal/pkg/errorsx" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" ) // BuildExecutionPlan 构建当前请求的 Skill 执行计划。 func BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) { - if ctx.AIAgentID <= 0 { - return nil, errorsx.InvalidParam("AIAgentID不能为空") - } - - aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), ctx.AIAgentID) - if aiAgent == nil { - return nil, errorsx.InvalidParam("AI Agent不存在") - } - aiConfig := repositories.AIConfigRepository.Get(sqls.DB(), aiAgent.AIConfigID) - if aiConfig == nil { - return nil, errorsx.InvalidParam("AI Agent关联的AI配置不存在") - } - - skill, matchReason, routeTrace, err := MatchSkill(execCtx, ctx, aiAgent, aiConfig) - if err != nil { - return nil, err - } - - return &ExecutionPlan{ - AIAgent: aiAgent, - AIConfig: aiConfig, - Skill: skill, - MatchReason: strings.TrimSpace(matchReason), - RouteTrace: routeTrace, - }, nil + return RuntimeService.BuildExecutionPlan(execCtx, ctx) } // WriteRunLog 写入 Skill 路由日志。 func WriteRunLog(log *models.SkillRunLog) error { - if log == nil { - return nil - } - return repositories.SkillRunLogRepository.Create(sqls.DB(), log) + return RuntimeService.WriteRunLog(log) } // Select 执行一次 Skill 路由并记录路由日志。 func Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) { - plan, err := BuildExecutionPlan(ctx, runtimeCtx) - if err != nil { - trace := &ExecutionTrace{Status: "route_error"} - log := BuildRunLog(runtimeCtx, nil, trace, err) - _ = WriteRunLog(log) - return nil, err - } - trace := &ExecutionTrace{Status: "ok"} - if plan == nil || plan.Skill == nil { - if plan != nil { - trace.Status = "not_matched" - trace.MatchReason = strings.TrimSpace(plan.MatchReason) - trace.Route = plan.RouteTrace - } - log := BuildRunLog(runtimeCtx, plan, trace, nil) - _ = WriteRunLog(log) - return &ExecutionResult{ - Plan: plan, - RunLog: log, - Trace: trace, - }, nil - } - trace.MatchReason = strings.TrimSpace(plan.MatchReason) - trace.Route = plan.RouteTrace - log := BuildRunLog(runtimeCtx, plan, trace, err) - if writeErr := WriteRunLog(log); writeErr != nil && err == nil { - err = writeErr - } - if err != nil { - return nil, err - } - return &ExecutionResult{ - Plan: plan, - RunLog: log, - Trace: trace, - }, nil + return RuntimeService.Select(ctx, runtimeCtx) } diff --git a/internal/ai/skills/service.go b/internal/ai/skills/service.go new file mode 100644 index 0000000..226c310 --- /dev/null +++ b/internal/ai/skills/service.go @@ -0,0 +1,97 @@ +package skills + +import ( + "context" + "strings" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/errorsx" + "cs-agent/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var RuntimeService = newService() + +func newService() *Service { + return &Service{} +} + +type Service struct{} + +// BuildExecutionPlan 构建当前请求的 Skill 执行计划。 +func (s *Service) BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) { + if ctx.AIAgentID <= 0 { + return nil, errorsx.InvalidParam("AIAgentID不能为空") + } + + aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), ctx.AIAgentID) + if aiAgent == nil { + return nil, errorsx.InvalidParam("AI Agent不存在") + } + aiConfig := repositories.AIConfigRepository.Get(sqls.DB(), aiAgent.AIConfigID) + if aiConfig == nil { + return nil, errorsx.InvalidParam("AI Agent关联的AI配置不存在") + } + + skill, matchReason, routeTrace, err := MatchSkill(execCtx, ctx, aiAgent, aiConfig) + if err != nil { + return nil, err + } + + return &ExecutionPlan{ + AIAgent: aiAgent, + AIConfig: aiConfig, + Skill: skill, + MatchReason: strings.TrimSpace(matchReason), + RouteTrace: routeTrace, + }, nil +} + +// WriteRunLog 写入 Skill 路由日志。 +func (s *Service) WriteRunLog(log *models.SkillRunLog) error { + if log == nil { + return nil + } + return repositories.SkillRunLogRepository.Create(sqls.DB(), log) +} + +// Select 执行一次 Skill 路由并记录路由日志。 +func (s *Service) Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) { + plan, err := s.BuildExecutionPlan(ctx, runtimeCtx) + if err != nil { + trace := &ExecutionTrace{Status: "route_error"} + log := BuildRunLog(runtimeCtx, nil, trace, err) + _ = s.WriteRunLog(log) + return nil, err + } + trace := &ExecutionTrace{Status: "ok"} + if plan == nil || plan.Skill == nil { + if plan != nil { + trace.Status = "not_matched" + trace.MatchReason = strings.TrimSpace(plan.MatchReason) + trace.Route = plan.RouteTrace + } + log := BuildRunLog(runtimeCtx, plan, trace, nil) + _ = s.WriteRunLog(log) + return &ExecutionResult{ + Plan: plan, + RunLog: log, + Trace: trace, + }, nil + } + trace.MatchReason = strings.TrimSpace(plan.MatchReason) + trace.Route = plan.RouteTrace + log := BuildRunLog(runtimeCtx, plan, trace, err) + if writeErr := s.WriteRunLog(log); writeErr != nil && err == nil { + err = writeErr + } + if err != nil { + return nil, err + } + return &ExecutionResult{ + Plan: plan, + RunLog: log, + Trace: trace, + }, nil +}