Files
ai-agent/internal/ai/skills/matcher.go
T

212 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package skills
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"cs-agent/internal/ai"
"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 {
Intents []string `json:"intents"`
}
// 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)
if skill == nil || skill.Status != enums.StatusOk {
return nil, "", nil, errorsx.InvalidParam("Skill 不存在或未启用")
}
return skill, "manual_skill_code", &RouteTrace{
Status: "manual_selected",
SelectedSkillCode: skill.Code,
}, nil
}
candidates := loadCandidateSkills(aiAgent)
trace := &RouteTrace{
Status: "started",
CandidateSkillCodes: make([]string, 0, len(candidates)),
}
for _, item := range candidates {
trace.CandidateSkillCodes = append(trace.CandidateSkillCodes, item.Code)
}
if len(candidates) == 0 {
trace.Status = "no_candidate"
return nil, "no_enabled_skill_bound", trace, nil
}
intentCode := strings.TrimSpace(ctx.IntentCode)
if intentCode != "" {
for _, item := range candidates {
if strings.EqualFold(strings.TrimSpace(item.Code), intentCode) {
trace.Status = "intent_selected"
trace.SelectedSkillCode = item.Code
return &item, "intent_code", trace, nil
}
}
}
if len(candidates) == 1 {
trace.Status = "single_candidate"
trace.SelectedSkillCode = candidates[0].Code
return &candidates[0], "single_candidate", trace, nil
}
selected, routeTrace, err := routeSkillWithLLM(execCtx, aiConfig, ctx.UserMessage, candidates)
if routeTrace != nil {
trace.Status = routeTrace.Status
trace.SelectedSkillCode = routeTrace.SelectedSkillCode
trace.RawDecision = routeTrace.RawDecision
trace.LatencyMs = routeTrace.LatencyMs
trace.Error = routeTrace.Error
}
if err != nil {
if trace.Error == "" {
trace.Error = err.Error()
}
return nil, "route_error", trace, err
}
if selected == nil {
if trace.Status == "started" {
trace.Status = "not_matched"
}
return nil, "route_none", trace, nil
}
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
}
func routeSkillWithLLM(ctx context.Context, aiConfig *models.AIConfig, userMessage string, candidates []models.SkillDefinition) (*models.SkillDefinition, *RouteTrace, error) {
trace := &RouteTrace{Status: "started"}
if aiConfig == nil {
trace.Status = "config_error"
trace.Error = "ai config is nil"
return nil, trace, errorsx.InvalidParam("Skill 路由依赖的 AI 配置不可用")
}
if len(candidates) == 0 {
trace.Status = "no_candidate"
return nil, trace, nil
}
userMessage = strings.TrimSpace(userMessage)
if userMessage == "" {
trace.Status = "empty_user_message"
return nil, trace, nil
}
systemPrompt := "你是客服技能路由器。你只能在候选 Skill 中选择一个最合适的 skillCode,或者返回 NONE。只有当用户问题与 Skill 的职责边界明确匹配时才选择;如果不明确、信息不足、多个 Skill 都不够确定,就返回 NONE。输出只能是 skillCode 或 NONE,不能输出其他内容。"
userPrompt := buildSkillRoutePrompt(userMessage, candidates)
startedAt := time.Now()
result, err := ai.LLM.ChatWithConfig(ctx, aiConfig, systemPrompt, userPrompt)
trace.LatencyMs = time.Since(startedAt).Milliseconds()
if err != nil {
trace.Status = "route_error"
trace.Error = err.Error()
return nil, trace, err
}
decision := normalizeRouteDecision(result.Content)
trace.RawDecision = strings.TrimSpace(result.Content)
if decision == "" || decision == "NONE" {
trace.Status = "not_matched"
return nil, trace, nil
}
for _, item := range candidates {
if strings.EqualFold(item.Code, decision) {
trace.Status = "llm_selected"
trace.SelectedSkillCode = item.Code
return &item, trace, nil
}
}
trace.Status = "invalid_decision"
trace.Error = fmt.Sprintf("invalid route decision: %s", decision)
return nil, trace, nil
}
func buildSkillRoutePrompt(userMessage string, candidates []models.SkillDefinition) string {
lines := make([]string, 0, len(candidates)+4)
lines = append(lines, "用户问题:")
lines = append(lines, strings.TrimSpace(userMessage))
lines = append(lines, "")
lines = append(lines, "候选 Skills")
for _, item := range candidates {
line := fmt.Sprintf("- skillCode=%s; name=%s; description=%s", strings.TrimSpace(item.Code), strings.TrimSpace(item.Name), strings.TrimSpace(item.Description))
if examples := parseSkillExamples(item.Examples); len(examples) > 0 {
line += "; examples=" + strings.Join(examples, " | ")
}
lines = append(lines, line)
}
lines = append(lines, "")
lines = append(lines, "请只输出一个 skillCode 或 NONE。")
return strings.Join(lines, "\n")
}
func parseSkillExamples(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ret = append(ret, item)
if len(ret) >= 3 {
break
}
}
return ret
}
func normalizeRouteDecision(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
raw = strings.Trim(raw, "`")
raw = strings.TrimSpace(raw)
if idx := strings.Index(raw, "\n"); idx >= 0 {
raw = raw[:idx]
}
raw = strings.TrimSpace(raw)
raw = strings.Trim(raw, "\"'")
if strings.EqualFold(raw, "NONE") {
return "NONE"
}
return raw
}