Refactor AI skill routing and introduce reply handling services
- Moved the skill routing logic from matcher.go to a new router.go file for better organization. - Implemented replyCommitService to handle sending AI replies and managing reply rounds. - Added replyInterruptService to manage conversation interrupts and resume handling. - Created replyRunLogService to log AI reply actions and their outcomes. - Introduced helper functions for building conversation interrupts and resolving prompts. - Added unit tests for the new services and functions to ensure correctness. - Removed unused code and optimized imports in matcher.go.
This commit is contained in:
@@ -2,12 +2,8 @@ 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"
|
||||
@@ -106,106 +102,3 @@ func loadCandidateSkills(aiAgent *models.AIAgent) []models.SkillDefinition {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
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 ""
|
||||
}
|
||||
if idx := strings.Index(raw, "\n"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.Trim(raw, "`")
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.Trim(raw, "\"'")
|
||||
if strings.EqualFold(raw, "NONE") {
|
||||
return "NONE"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
)
|
||||
|
||||
func TestParseSkillExamples(t *testing.T) {
|
||||
examples := parseSkillExamples(`[" 退款进度 ","","发票补开","修改收货地址","多余示例"]`)
|
||||
if len(examples) != 3 {
|
||||
t.Fatalf("expected 3 examples, got %d", len(examples))
|
||||
}
|
||||
if examples[0] != "退款进度" || examples[1] != "发票补开" || examples[2] != "修改收货地址" {
|
||||
t.Fatalf("unexpected examples: %#v", examples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRouteDecision(t *testing.T) {
|
||||
if got := normalizeRouteDecision("```refund_skill```\n补充说明"); got != "refund_skill" {
|
||||
t.Fatalf("unexpected normalized decision: %q", got)
|
||||
}
|
||||
if got := normalizeRouteDecision(" none "); got != "NONE" {
|
||||
t.Fatalf("expected NONE, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillRoutePrompt(t *testing.T) {
|
||||
prompt := buildSkillRoutePrompt("我要申请退款", []models.SkillDefinition{
|
||||
{
|
||||
Code: "refund_skill",
|
||||
Name: "退款处理",
|
||||
Description: "负责退款和退货相关问题",
|
||||
Examples: `["退款进度","退货运费"]`,
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(prompt, "skillCode=refund_skill") {
|
||||
t.Fatalf("expected prompt to include skill code, got %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "examples=退款进度 | 退货运费") {
|
||||
t.Fatalf("expected prompt to include examples, got %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "请只输出一个 skillCode 或 NONE。") {
|
||||
t.Fatalf("expected prompt to include output constraint, got %q", prompt)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user