Init
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
openai "github.com/openai/openai-go/v3"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
type EmbeddingResult struct {
|
||||
Vector []float32
|
||||
TokensUsed int
|
||||
ModelName string
|
||||
Dimension int
|
||||
}
|
||||
|
||||
type embedding struct{}
|
||||
|
||||
var Embedding = &embedding{}
|
||||
|
||||
func (s *embedding) GetModel(ctx context.Context) (*models.AIConfig, error) {
|
||||
config, err := GetEnabledAIConfig(enums.AIModelTypeEmbedding)
|
||||
if err != nil {
|
||||
return nil, errorsx.BusinessError(2001, "未配置可用的 Embedding 模型")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *embedding) GenerateEmbedding(ctx context.Context, text string) (*EmbeddingResult, error) {
|
||||
if text == "" {
|
||||
return nil, errorsx.InvalidParam("文本内容不能为空")
|
||||
}
|
||||
|
||||
result, err := s.callEmbeddingAPI(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *embedding) GenerateBatchEmbeddings(ctx context.Context, texts []string) ([]EmbeddingResult, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, errorsx.InvalidParam("文本列表不能为空")
|
||||
}
|
||||
|
||||
results := make([]EmbeddingResult, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
result, err := s.callEmbeddingAPI(ctx, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate embedding for text: %w", err)
|
||||
}
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *embedding) callEmbeddingAPI(ctx context.Context, text string) (*EmbeddingResult, error) {
|
||||
config, err := GetEnabledAIConfig(enums.AIModelTypeEmbedding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := newOpenAIClient(config)
|
||||
embeddingResp, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
|
||||
Input: openai.EmbeddingNewParamsInputUnion{
|
||||
OfString: openai.String(text),
|
||||
},
|
||||
Model: openai.EmbeddingModel(config.ModelName),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call embedding api: %w", err)
|
||||
}
|
||||
|
||||
if len(embeddingResp.Data) == 0 {
|
||||
return nil, fmt.Errorf("no embedding data in response")
|
||||
}
|
||||
vector := make([]float32, 0, len(embeddingResp.Data[0].Embedding))
|
||||
for _, item := range embeddingResp.Data[0].Embedding {
|
||||
vector = append(vector, float32(item))
|
||||
}
|
||||
|
||||
return &EmbeddingResult{
|
||||
Vector: vector,
|
||||
TokensUsed: int(embeddingResp.Usage.TotalTokens),
|
||||
ModelName: embeddingResp.Model,
|
||||
Dimension: len(vector),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *embedding) GetDimension(ctx context.Context) (int, error) {
|
||||
model, err := s.GetModel(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return model.Dimension, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
openai "github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/shared"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type ChatCompletionResult struct {
|
||||
Content string
|
||||
ModelName string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
|
||||
type llm struct{}
|
||||
|
||||
var LLM = &llm{}
|
||||
|
||||
func (s *llm) Chat(ctx context.Context, systemPrompt string, userPrompt string) (*ChatCompletionResult, error) {
|
||||
config, err := GetEnabledAIConfig(enums.AIModelTypeLLM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ChatWithConfig(ctx, config, systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (s *llm) ChatWithConfig(ctx context.Context, config *models.AIConfig, systemPrompt string, userPrompt string) (*ChatCompletionResult, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("ai config is nil")
|
||||
}
|
||||
|
||||
messages := make([]openai.ChatCompletionMessageParamUnion, 0, 2)
|
||||
if strs.IsNotBlank(systemPrompt) {
|
||||
messages = append(messages, openai.ChatCompletionMessageParamUnion{
|
||||
OfSystem: &openai.ChatCompletionSystemMessageParam{
|
||||
Content: openai.ChatCompletionSystemMessageParamContentUnion{
|
||||
OfString: openai.String(systemPrompt),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
messages = append(messages, openai.ChatCompletionMessageParamUnion{
|
||||
OfUser: &openai.ChatCompletionUserMessageParam{
|
||||
Content: openai.ChatCompletionUserMessageParamContentUnion{
|
||||
OfString: openai.String(userPrompt),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Messages: messages,
|
||||
Model: shared.ChatModel(config.ModelName),
|
||||
}
|
||||
if config.MaxOutputTokens > 0 {
|
||||
params.MaxCompletionTokens = openai.Int(int64(config.MaxOutputTokens))
|
||||
}
|
||||
applyProviderSpecificChatParams(¶ms, config)
|
||||
|
||||
client := newOpenAIClient(config)
|
||||
chatResp, err := client.Chat.Completions.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call llm api (model=%s provider=%s system_chars=%d user_chars=%d max_output_tokens=%d): %w",
|
||||
config.ModelName, config.Provider, utf8.RuneCountInString(systemPrompt), utf8.RuneCountInString(userPrompt), config.MaxOutputTokens, err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("no llm choices in response")
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||
return &ChatCompletionResult{
|
||||
Content: content,
|
||||
ModelName: config.ModelName,
|
||||
PromptTokens: int(chatResp.Usage.PromptTokens),
|
||||
CompletionTokens: int(chatResp.Usage.CompletionTokens),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyProviderSpecificChatParams(params *openai.ChatCompletionNewParams, config *models.AIConfig) {
|
||||
if params == nil || config == nil {
|
||||
return
|
||||
}
|
||||
if isDashScopeQwenThinkingModel(config) {
|
||||
params.SetExtraFields(map[string]any{
|
||||
"enable_thinking": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isDashScopeQwenThinkingModel(config *models.AIConfig) bool {
|
||||
if config == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(config.BaseURL))
|
||||
modelName := strings.ToLower(strings.TrimSpace(config.ModelName))
|
||||
return strings.Contains(baseURL, "dashscope.aliyuncs.com") && strings.HasPrefix(modelName, "qwen3")
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package mcps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type Client struct{}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
func (c *Client) TestConnection(ctx context.Context, cfg ServerConfig) (*ConnectionResult, error) {
|
||||
session, closeFn, err := c.connect(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
initResult := session.InitializeResult()
|
||||
serverName := ""
|
||||
version := ""
|
||||
protocol := ""
|
||||
if initResult != nil {
|
||||
serverName = initResult.ServerInfo.Name
|
||||
version = initResult.ServerInfo.Version
|
||||
protocol = initResult.ProtocolVersion
|
||||
}
|
||||
return &ConnectionResult{
|
||||
ServerCode: cfg.Code,
|
||||
Endpoint: cfg.Endpoint,
|
||||
Protocol: protocol,
|
||||
ServerName: serverName,
|
||||
Version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTools(ctx context.Context, cfg ServerConfig) ([]ToolInfo, error) {
|
||||
session, closeFn, err := c.connect(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
result, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("列出 MCP 工具失败: %w", err)
|
||||
}
|
||||
ret := make([]ToolInfo, 0, len(result.Tools))
|
||||
for _, tool := range result.Tools {
|
||||
ret = append(ret, ToolInfo{
|
||||
Name: tool.Name,
|
||||
Title: tool.Title,
|
||||
Description: tool.Description,
|
||||
InputSchema: tool.InputSchema,
|
||||
OutputSchema: tool.OutputSchema,
|
||||
})
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *Client) CallTool(ctx context.Context, cfg ServerConfig, toolName string, arguments map[string]any) (*ToolCallResult, error) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return nil, errorsx.InvalidParam("toolName不能为空")
|
||||
}
|
||||
|
||||
session, closeFn, err := c.connect(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: toolName,
|
||||
Arguments: arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("调用 MCP 工具失败: %w", err)
|
||||
}
|
||||
return &ToolCallResult{
|
||||
ServerCode: cfg.Code,
|
||||
ToolName: toolName,
|
||||
IsError: result.IsError,
|
||||
Content: convertContents(result.Content),
|
||||
StructuredContent: result.StructuredContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context, cfg ServerConfig) (*mcp.ClientSession, func(), error) {
|
||||
if strings.TrimSpace(cfg.Code) == "" {
|
||||
return nil, nil, errorsx.InvalidParam("serverCode不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, nil, errorsx.InvalidParam("MCP endpoint不能为空")
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
connCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &headerRoundTripper{
|
||||
next: http.DefaultTransport,
|
||||
headers: cfg.Headers,
|
||||
},
|
||||
}
|
||||
client := mcp.NewClient(&mcp.Implementation{
|
||||
Name: "cs-agent-mcp-client",
|
||||
Version: "v1",
|
||||
}, nil)
|
||||
transport := &mcp.StreamableClientTransport{
|
||||
Endpoint: cfg.Endpoint,
|
||||
HTTPClient: httpClient,
|
||||
MaxRetries: 0,
|
||||
DisableStandaloneSSE: true,
|
||||
}
|
||||
session, err := client.Connect(connCtx, transport, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("连接 MCP Server 失败: %w", err)
|
||||
}
|
||||
return session, func() {
|
||||
_ = session.Close()
|
||||
cancel()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertContents(contents []mcp.Content) []ToolResultContent {
|
||||
ret := make([]ToolResultContent, 0, len(contents))
|
||||
for _, item := range contents {
|
||||
switch v := item.(type) {
|
||||
case *mcp.TextContent:
|
||||
ret = append(ret, ToolResultContent{
|
||||
Type: "text",
|
||||
Text: v.Text,
|
||||
})
|
||||
case *mcp.ImageContent:
|
||||
ret = append(ret, ToolResultContent{
|
||||
Type: "image",
|
||||
Data: map[string]any{
|
||||
"mimeType": v.MIMEType,
|
||||
"data": v.Data,
|
||||
},
|
||||
})
|
||||
case *mcp.AudioContent:
|
||||
ret = append(ret, ToolResultContent{
|
||||
Type: "audio",
|
||||
Data: map[string]any{
|
||||
"mimeType": v.MIMEType,
|
||||
"data": v.Data,
|
||||
},
|
||||
})
|
||||
case *mcp.EmbeddedResource:
|
||||
ret = append(ret, ToolResultContent{
|
||||
Type: "resource",
|
||||
Data: v.Resource,
|
||||
})
|
||||
default:
|
||||
ret = append(ret, ToolResultContent{
|
||||
Type: "unknown",
|
||||
Data: v,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type headerRoundTripper struct {
|
||||
next http.RoundTripper
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
func (r *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
next := r.next
|
||||
if next == nil {
|
||||
next = http.DefaultTransport
|
||||
}
|
||||
clone := req.Clone(req.Context())
|
||||
for key, value := range r.headers {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
clone.Header.Set(key, value)
|
||||
}
|
||||
return next.RoundTrip(clone)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type systemToolProvider struct{}
|
||||
|
||||
func NewSystemToolProvider() ToolProvider {
|
||||
return &systemToolProvider{}
|
||||
}
|
||||
|
||||
func (p *systemToolProvider) Name() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
func (p *systemToolProvider) Register(server *mcp.Server) error {
|
||||
mcp.AddTool(
|
||||
server,
|
||||
&mcp.Tool{
|
||||
Name: "server_time",
|
||||
Description: "获取当前服务端时间,可选传入时区。",
|
||||
},
|
||||
func(_ context.Context, _ *mcp.CallToolRequest, args serverTimeArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||
loc := time.Local
|
||||
timezone := args.Timezone
|
||||
if timezone == "" {
|
||||
timezone = "Local"
|
||||
} else if loaded, err := time.LoadLocation(timezone); err == nil {
|
||||
loc = loaded
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
return nil, map[string]any{
|
||||
"timezone": timezone,
|
||||
"timestamp": now.Format("2006-01-02 15:04:05"),
|
||||
"unix": now.Unix(),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
mcp.AddTool(
|
||||
server,
|
||||
&mcp.Tool{
|
||||
Name: "service_info",
|
||||
Description: "查看当前 cs-agent 服务的基础运行信息。",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, map[string]any, error) {
|
||||
cfg := config.Current()
|
||||
return nil, map[string]any{
|
||||
"name": "cs-agent",
|
||||
"version": "v1",
|
||||
"mcpPath": "/api/mcp",
|
||||
"port": cfg.Server.Port,
|
||||
"mcpEnabled": cfg.MCP.Enabled,
|
||||
"vectorDb": cfg.VectorDB.Type,
|
||||
"storageType": cfg.Storage.Default,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type serverTimeArgs struct {
|
||||
Timezone string `json:"timezone,omitempty" jsonschema:"可选时区名称,例如 Asia/Shanghai 或 UTC"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type ToolProvider interface {
|
||||
Name() string
|
||||
Register(server *mcp.Server) error
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package mcps
|
||||
|
||||
import (
|
||||
"cs-agent/internal/ai/mcps/providers"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func defaultProviders() []providers.ToolProvider {
|
||||
return []providers.ToolProvider{
|
||||
providers.NewSystemToolProvider(),
|
||||
// 在这里注册其他的 ToolProvider
|
||||
}
|
||||
}
|
||||
|
||||
func registerProviders(server *mcp.Server) error {
|
||||
for _, provider := range defaultProviders() {
|
||||
if err := provider.Register(server); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package mcps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
type RuntimeService struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
var Runtime = NewRuntimeService()
|
||||
|
||||
func NewRuntimeService() *RuntimeService {
|
||||
return &RuntimeService{
|
||||
client: NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RuntimeService) CallTool(ctx context.Context, serverCode string, toolName string, arguments map[string]any) (*ToolCallResult, error) {
|
||||
server, err := s.resolveServer(serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.client.CallTool(ctx, server, toolName, arguments)
|
||||
}
|
||||
|
||||
func (s *RuntimeService) ListTools(ctx context.Context, serverCode string) ([]ToolInfo, error) {
|
||||
server, err := s.resolveServer(serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.client.ListTools(ctx, server)
|
||||
}
|
||||
|
||||
func (s *RuntimeService) resolveServer(serverCode string) (ServerConfig, error) {
|
||||
cfg := config.Current()
|
||||
if !cfg.MCP.Enabled {
|
||||
return ServerConfig{}, errorsx.InvalidParam("MCP未启用")
|
||||
}
|
||||
serverCode = strings.TrimSpace(serverCode)
|
||||
if serverCode == "" {
|
||||
return ServerConfig{}, errorsx.InvalidParam("serverCode不能为空")
|
||||
}
|
||||
server, ok := cfg.MCP.Servers[serverCode]
|
||||
if !ok {
|
||||
return ServerConfig{}, errorsx.InvalidParam("MCP服务配置不存在")
|
||||
}
|
||||
if !server.Enabled {
|
||||
return ServerConfig{}, errorsx.InvalidParam("MCP服务未启用")
|
||||
}
|
||||
return ServerConfig{
|
||||
Code: serverCode,
|
||||
Endpoint: strings.TrimSpace(server.Endpoint),
|
||||
TimeoutMS: server.TimeoutMS,
|
||||
Headers: cloneRuntimeHeaders(server.Headers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneRuntimeHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]string, len(headers))
|
||||
for key, value := range headers {
|
||||
ret[key] = value
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mcps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func NewHTTPHandler() http.Handler {
|
||||
server := newServer()
|
||||
return mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
|
||||
return server
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
JSONResponse: true,
|
||||
SessionTimeout: 2 * time.Minute,
|
||||
})
|
||||
}
|
||||
|
||||
func newServer() *mcp.Server {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "cs-agent-mcp-server",
|
||||
Title: "CS Agent MCP Server",
|
||||
Version: "v1",
|
||||
WebsiteURL: "https://github.com/modelcontextprotocol",
|
||||
}, nil)
|
||||
if err := registerProviders(server); err != nil {
|
||||
panic(fmt.Sprintf("register mcp providers failed: %v", err))
|
||||
}
|
||||
return server
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package mcps
|
||||
|
||||
type ServerConfig struct {
|
||||
Code string
|
||||
Endpoint string
|
||||
TimeoutMS int
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ServerInfo struct {
|
||||
Code string `json:"code"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
type ConnectionResult struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Protocol string `json:"protocol"`
|
||||
ServerName string `json:"serverName"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type ToolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
InputSchema any `json:"inputSchema"`
|
||||
OutputSchema any `json:"outputSchema,omitempty"`
|
||||
}
|
||||
|
||||
type ToolResultContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallResult struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
IsError bool `json:"isError"`
|
||||
Content []ToolResultContent `json:"content"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
openai "github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
)
|
||||
|
||||
func newOpenAIClient(config *models.AIConfig) openai.Client {
|
||||
opts := []option.RequestOption{
|
||||
option.WithAPIKey(config.APIKey),
|
||||
option.WithBaseURL(config.BaseURL),
|
||||
}
|
||||
if config.TimeoutMS > 0 {
|
||||
opts = append(opts, option.WithRequestTimeout(time.Duration(config.TimeoutMS)*time.Millisecond))
|
||||
}
|
||||
if config.MaxRetryCount >= 0 {
|
||||
opts = append(opts, option.WithMaxRetries(config.MaxRetryCount))
|
||||
}
|
||||
|
||||
return openai.NewClient(opts...)
|
||||
}
|
||||
|
||||
func GetEnabledAIConfig(modelType enums.AIModelType) (*models.AIConfig, error) {
|
||||
item := repositories.AIConfigRepository.GetEnabled(sqls.DB(), modelType)
|
||||
if item == nil {
|
||||
return nil, errorsx.BusinessError(2005, "未配置可用的 AI 配置")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
)
|
||||
|
||||
type answer struct {
|
||||
}
|
||||
|
||||
var Answer = &answer{}
|
||||
|
||||
func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchRequest) (*response.KnowledgeSearchResponse, error) {
|
||||
if strings.TrimSpace(req.Question) == "" {
|
||||
return nil, errorsx.InvalidParam("问题不能为空")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
results, err := s.retrieve(req, ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respResults := make([]response.KnowledgeSearchResult, 0, len(results))
|
||||
for _, item := range results {
|
||||
respResults = append(respResults, response.KnowledgeSearchResult{
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
ChunkID: item.ChunkID,
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
FaqID: item.FaqID,
|
||||
FaqQuestion: item.FaqQuestion,
|
||||
ChunkNo: item.ChunkNo,
|
||||
Title: item.Title,
|
||||
SectionPath: item.SectionPath,
|
||||
Content: item.Content,
|
||||
Score: float64(item.Score),
|
||||
})
|
||||
}
|
||||
|
||||
return &response.KnowledgeSearchResponse{
|
||||
Question: req.Question,
|
||||
Results: respResults,
|
||||
HitCount: len(respResults),
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerRequest, operator *dto.AuthPrincipal) (*response.KnowledgeAnswerResponse, error) {
|
||||
if strings.TrimSpace(req.Question) == "" {
|
||||
return nil, errorsx.InvalidParam("问题不能为空")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
|
||||
retrieveStartedAt := time.Now()
|
||||
results, err := s.retrieve(request.KnowledgeSearchRequest{
|
||||
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
|
||||
Question: req.Question,
|
||||
TopK: req.TopK,
|
||||
ScoreThreshold: req.ScoreThreshold,
|
||||
RerankLimit: req.RerankLimit,
|
||||
}, ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
retrieveMs := time.Since(retrieveStartedAt).Milliseconds()
|
||||
knowledgeBase := s.resolveAnswerKnowledgeBase(req.KnowledgeBaseIDs, results)
|
||||
contextResults := buildContextHits(Retrieve.SelectContextResults(results, 4000))
|
||||
|
||||
hits := make([]response.KnowledgeSearchResult, 0, len(results))
|
||||
topScore := 0.0
|
||||
for i, item := range results {
|
||||
score := float64(item.Score)
|
||||
if i == 0 {
|
||||
topScore = score
|
||||
}
|
||||
hits = append(hits, response.KnowledgeSearchResult{
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
ChunkID: item.ChunkID,
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
FaqID: item.FaqID,
|
||||
FaqQuestion: item.FaqQuestion,
|
||||
ChunkNo: item.ChunkNo,
|
||||
Title: item.Title,
|
||||
SectionPath: item.SectionPath,
|
||||
Content: item.Content,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
citations := buildKnowledgeCitations(hits, 3)
|
||||
|
||||
answerMode := enums.KnowledgeAnswerMode(req.AnswerMode)
|
||||
if answerMode == 0 {
|
||||
if knowledgeBase != nil {
|
||||
answerMode = enums.KnowledgeAnswerMode(knowledgeBase.AnswerMode)
|
||||
}
|
||||
if answerMode == 0 {
|
||||
answerMode = enums.KnowledgeAnswerModeStrict
|
||||
}
|
||||
}
|
||||
|
||||
fallbackMode := enums.KnowledgeFallbackMode(req.FallbackMode)
|
||||
if fallbackMode == 0 {
|
||||
if knowledgeBase != nil {
|
||||
fallbackMode = enums.KnowledgeFallbackMode(knowledgeBase.FallbackMode)
|
||||
}
|
||||
if fallbackMode == 0 {
|
||||
fallbackMode = enums.KnowledgeFallbackModeNoAnswer
|
||||
}
|
||||
}
|
||||
|
||||
answerStatus := enums.KnowledgeAnswerStatusNormal
|
||||
answer := ""
|
||||
modelName := ""
|
||||
promptTokens := 0
|
||||
completionTokens := 0
|
||||
generateStartedAt := time.Now()
|
||||
|
||||
if len(hits) == 0 {
|
||||
answerStatus = enums.KnowledgeAnswerStatusNoAnswer
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
} else {
|
||||
contextText := Retrieve.BuildContext(ctx, results, 4000)
|
||||
systemPrompt := buildAnswerSystemPrompt(answerMode)
|
||||
userPrompt := fmt.Sprintf("用户问题:%s\n\n参考资料:\n%s", req.Question, contextText)
|
||||
llmResult, llmErr := ai.LLM.Chat(ctx, systemPrompt, userPrompt)
|
||||
if llmErr != nil {
|
||||
answerStatus = enums.KnowledgeAnswerStatusFallback
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
} else {
|
||||
answer = llmResult.Content
|
||||
modelName = llmResult.ModelName
|
||||
promptTokens = llmResult.PromptTokens
|
||||
completionTokens = llmResult.CompletionTokens
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
answerStatus = enums.KnowledgeAnswerStatusFallback
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
generateMs := time.Since(generateStartedAt).Milliseconds()
|
||||
rerankLimit := 0
|
||||
chunkProvider := ""
|
||||
chunkTargetTokens := 0
|
||||
chunkMaxTokens := 0
|
||||
chunkOverlapTokens := 0
|
||||
if knowledgeBase != nil {
|
||||
rerankLimit = resolveRerankLimit(req.RerankLimit, knowledgeBase.DefaultRerankLimit)
|
||||
chunkProvider = knowledgeBase.ChunkProvider
|
||||
chunkTargetTokens = knowledgeBase.ChunkTargetTokens
|
||||
chunkMaxTokens = knowledgeBase.ChunkMaxTokens
|
||||
chunkOverlapTokens = knowledgeBase.ChunkOverlapTokens
|
||||
}
|
||||
|
||||
logItem, err := RetrieveLog.CreateRetrieveLog(&CreateRetrieveLogRequest{
|
||||
KnowledgeBaseID: firstKnowledgeBaseID(req.KnowledgeBaseIDs),
|
||||
Channel: defaultRetrieveChannel(req.Channel),
|
||||
Scene: defaultRetrieveScene(req.Scene),
|
||||
SessionID: req.SessionID,
|
||||
ConversationID: req.ConversationID,
|
||||
Question: req.Question,
|
||||
RewriteQuestion: "",
|
||||
Answer: answer,
|
||||
AnswerStatus: int(answerStatus),
|
||||
ChunkProvider: chunkProvider,
|
||||
ChunkTargetTokens: chunkTargetTokens,
|
||||
ChunkMaxTokens: chunkMaxTokens,
|
||||
ChunkOverlapTokens: chunkOverlapTokens,
|
||||
RerankEnabled: rerankLimit > 0,
|
||||
RerankLimit: rerankLimit,
|
||||
Hits: hits,
|
||||
UsedHits: contextResults,
|
||||
Citations: citations,
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
RetrieveMs: retrieveMs,
|
||||
GenerateMs: generateMs,
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
ModelName: modelName,
|
||||
}, operator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.KnowledgeAnswerResponse{
|
||||
Question: req.Question,
|
||||
Answer: answer,
|
||||
AnswerStatus: int(answerStatus),
|
||||
AnswerStatusName: getAnswerStatusName(answerStatus),
|
||||
Citations: citations,
|
||||
Hits: hits,
|
||||
HitCount: len(hits),
|
||||
TopScore: topScore,
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
RetrieveMs: retrieveMs,
|
||||
GenerateMs: generateMs,
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
ModelName: modelName,
|
||||
RetrieveLogID: logItem.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildContextHits(results []RetrieveResult) []response.KnowledgeSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
hits := make([]response.KnowledgeSearchResult, 0, len(results))
|
||||
for _, item := range results {
|
||||
hits = append(hits, response.KnowledgeSearchResult{
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
ChunkID: item.ChunkID,
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
FaqID: item.FaqID,
|
||||
FaqQuestion: item.FaqQuestion,
|
||||
ChunkNo: item.ChunkNo,
|
||||
Title: item.Title,
|
||||
SectionPath: item.SectionPath,
|
||||
Content: item.Content,
|
||||
Score: float64(item.Score),
|
||||
})
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func buildKnowledgeCitations(hits []response.KnowledgeSearchResult, limit int) []response.KnowledgeCitation {
|
||||
if len(hits) == 0 || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
citations := make([]response.KnowledgeCitation, 0, limit)
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range hits {
|
||||
key := fmt.Sprintf("%d|%d|%s|%d", item.DocumentID, item.FaqID, item.SectionPath, item.ChunkNo)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
citations = append(citations, response.KnowledgeCitation{
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
FaqID: item.FaqID,
|
||||
FaqQuestion: item.FaqQuestion,
|
||||
ChunkNo: item.ChunkNo,
|
||||
Title: item.Title,
|
||||
SectionPath: item.SectionPath,
|
||||
Snippet: truncateCitationSnippet(item.Content, 120),
|
||||
Score: item.Score,
|
||||
})
|
||||
if len(citations) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return citations
|
||||
}
|
||||
|
||||
func truncateCitationSnippet(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func (s *answer) BuildDocumentIndex(ctx context.Context, documentID int64) error {
|
||||
return Index.IndexDocumentByID(ctx, documentID)
|
||||
}
|
||||
|
||||
func (s *answer) retrieve(req request.KnowledgeSearchRequest, ctx context.Context) ([]RetrieveResult, error) {
|
||||
if len(normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)) == 0 {
|
||||
return nil, errorsx.InvalidParam("知识库不能为空")
|
||||
}
|
||||
knowledgeBases := s.loadKnowledgeBases(req.KnowledgeBaseIDs)
|
||||
|
||||
results, err := Retrieve.Retrieve(ctx, RetrieveRequest{
|
||||
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
|
||||
Query: req.Question,
|
||||
TopK: req.TopK,
|
||||
ScoreThreshold: req.ScoreThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaultRerankLimit := resolveDefaultRerankLimit(knowledgeBases)
|
||||
rerankLimit := resolveRerankLimit(req.RerankLimit, defaultRerankLimit)
|
||||
if rerankLimit > 0 && len(results) > rerankLimit {
|
||||
return Retrieve.RetrieveWithRerank(ctx, RetrieveRequest{
|
||||
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
|
||||
Query: req.Question,
|
||||
TopK: req.TopK,
|
||||
ScoreThreshold: req.ScoreThreshold,
|
||||
}, rerankLimit)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *answer) loadKnowledgeBases(knowledgeBaseIDs []int64) []models.KnowledgeBase {
|
||||
normalized := normalizeKnowledgeBaseIDs(knowledgeBaseIDs)
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := repositories.KnowledgeBaseRepository.Find(sqls.DB(), sqls.NewCnd().In("id", normalized))
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
itemMap := make(map[int64]models.KnowledgeBase, len(items))
|
||||
for _, item := range items {
|
||||
itemMap[item.ID] = item
|
||||
}
|
||||
results := make([]models.KnowledgeBase, 0, len(normalized))
|
||||
for _, id := range normalized {
|
||||
if item, ok := itemMap[id]; ok {
|
||||
results = append(results, item)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *answer) resolvePrimaryKnowledgeBase(knowledgeBaseIDs []int64) *models.KnowledgeBase {
|
||||
items := s.loadKnowledgeBases(knowledgeBaseIDs)
|
||||
for _, item := range items {
|
||||
return &item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *answer) resolveAnswerKnowledgeBase(knowledgeBaseIDs []int64, results []RetrieveResult) *models.KnowledgeBase {
|
||||
items := s.loadKnowledgeBases(knowledgeBaseIDs)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(results) > 0 {
|
||||
for _, item := range items {
|
||||
if item.ID == results[0].KnowledgeBaseID {
|
||||
return &item
|
||||
}
|
||||
}
|
||||
}
|
||||
return &items[0]
|
||||
}
|
||||
|
||||
func firstKnowledgeBaseID(ids []int64) int64 {
|
||||
normalized := normalizeKnowledgeBaseIDs(ids)
|
||||
if len(normalized) == 0 {
|
||||
return 0
|
||||
}
|
||||
return normalized[0]
|
||||
}
|
||||
|
||||
func resolveRerankLimit(requestLimit int, defaultLimit int) int {
|
||||
if requestLimit > 0 {
|
||||
return requestLimit
|
||||
}
|
||||
if defaultLimit > 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveDefaultRerankLimit(items []models.KnowledgeBase) int {
|
||||
limit := 0
|
||||
for _, item := range items {
|
||||
if item.DefaultRerankLimit > limit {
|
||||
limit = item.DefaultRerankLimit
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func buildAnswerSystemPrompt(answerMode enums.KnowledgeAnswerMode) string {
|
||||
if answerMode == enums.KnowledgeAnswerModeAssist {
|
||||
return "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。"
|
||||
}
|
||||
return "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。"
|
||||
}
|
||||
|
||||
func buildFallbackAnswer(fallbackMode enums.KnowledgeFallbackMode) string {
|
||||
switch fallbackMode {
|
||||
case enums.KnowledgeFallbackModeSuggestRetry:
|
||||
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
|
||||
case enums.KnowledgeFallbackModeTransferHuman:
|
||||
return "当前知识库里没有找到足够明确的信息,建议转人工进一步处理。"
|
||||
default:
|
||||
return "当前知识库暂无明确信息。"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRetrieveChannel(channel string) string {
|
||||
if strings.TrimSpace(channel) == "" {
|
||||
return string(enums.KnowledgeRetrieveChannelDebug)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
func defaultRetrieveScene(scene string) string {
|
||||
if strings.TrimSpace(scene) == "" {
|
||||
return string(enums.KnowledgeRetrieveSceneQA)
|
||||
}
|
||||
return scene
|
||||
}
|
||||
|
||||
func getAnswerStatusName(status enums.KnowledgeAnswerStatus) string {
|
||||
switch status {
|
||||
case enums.KnowledgeAnswerStatusNormal:
|
||||
return "正常"
|
||||
case enums.KnowledgeAnswerStatusNoAnswer:
|
||||
return "无答案"
|
||||
case enums.KnowledgeAnswerStatusFallback:
|
||||
return "兜底"
|
||||
case enums.KnowledgeAnswerStatusBlocked:
|
||||
return "风控拦截"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestBuildFallbackAnswer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode enums.KnowledgeFallbackMode
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no answer",
|
||||
mode: enums.KnowledgeFallbackModeNoAnswer,
|
||||
expected: "当前知识库暂无明确信息。",
|
||||
},
|
||||
{
|
||||
name: "suggest retry",
|
||||
mode: enums.KnowledgeFallbackModeSuggestRetry,
|
||||
expected: "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。",
|
||||
},
|
||||
{
|
||||
name: "transfer human",
|
||||
mode: enums.KnowledgeFallbackModeTransferHuman,
|
||||
expected: "当前知识库里没有找到足够明确的信息,建议转人工进一步处理。",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := buildFallbackAnswer(tt.mode); got != tt.expected {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnswerStatusName(t *testing.T) {
|
||||
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
|
||||
t.Fatalf("expected no-answer label, got %q", got)
|
||||
}
|
||||
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusFallback); got != "兜底" {
|
||||
t.Fatalf("expected fallback label, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRerankLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestLimit int
|
||||
defaultLimit int
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "request overrides default",
|
||||
requestLimit: 3,
|
||||
defaultLimit: 5,
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "default used when request missing",
|
||||
requestLimit: 0,
|
||||
defaultLimit: 5,
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "zero when both missing",
|
||||
requestLimit: 0,
|
||||
defaultLimit: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := resolveRerankLimit(tt.requestLimit, tt.defaultLimit); got != tt.expected {
|
||||
t.Fatalf("%s: expected %d, got %d", tt.name, tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultRerankLimit(t *testing.T) {
|
||||
items := []models.KnowledgeBase{
|
||||
{ID: 11, DefaultRerankLimit: 3},
|
||||
{ID: 22, DefaultRerankLimit: 7},
|
||||
{ID: 33, DefaultRerankLimit: 5},
|
||||
}
|
||||
|
||||
if got := resolveDefaultRerankLimit(items); got != 7 {
|
||||
t.Fatalf("expected max rerank limit 7, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKnowledgeCitations(t *testing.T) {
|
||||
hits := []response.KnowledgeSearchResult{
|
||||
{
|
||||
DocumentID: 11,
|
||||
DocumentTitle: "退款手册",
|
||||
ChunkNo: 0,
|
||||
Title: "退款说明",
|
||||
SectionPath: "售后 > 退款说明",
|
||||
Content: "退款申请提交后,预计1-3个工作日到账。",
|
||||
Score: 0.91,
|
||||
},
|
||||
{
|
||||
DocumentID: 11,
|
||||
DocumentTitle: "退款手册",
|
||||
ChunkNo: 0,
|
||||
Title: "退款说明",
|
||||
SectionPath: "售后 > 退款说明",
|
||||
Content: "重复内容",
|
||||
Score: 0.89,
|
||||
},
|
||||
}
|
||||
|
||||
citations := buildKnowledgeCitations(hits, 3)
|
||||
if len(citations) != 1 {
|
||||
t.Fatalf("expected 1 citation, got %d", len(citations))
|
||||
}
|
||||
if citations[0].DocumentID != 11 {
|
||||
t.Fatalf("expected document id 11, got %d", citations[0].DocumentID)
|
||||
}
|
||||
if citations[0].SectionPath != "售后 > 退款说明" {
|
||||
t.Fatalf("unexpected section path: %q", citations[0].SectionPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type fixedProvider struct{}
|
||||
|
||||
func NewFixedProvider() Provider {
|
||||
return &fixedProvider{}
|
||||
}
|
||||
|
||||
func (p *fixedProvider) Name() string {
|
||||
return string(enums.KnowledgeChunkProviderFixed)
|
||||
}
|
||||
|
||||
func (p *fixedProvider) Supports(contentType enums.KnowledgeDocumentContentType) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *fixedProvider) Chunk(ctx context.Context, req *ChunkRequest) ([]ChunkResult, error) {
|
||||
text := req.PlainText
|
||||
if text == "" {
|
||||
text = req.Content
|
||||
}
|
||||
parts := splitPlainText(text, req.Options)
|
||||
results := make([]ChunkResult, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
results = append(results, ChunkResult{
|
||||
ChunkNo: i,
|
||||
Title: req.DocumentTitle,
|
||||
Content: part,
|
||||
ChunkType: enums.KnowledgeChunkTypeText,
|
||||
SectionPath: req.DocumentTitle,
|
||||
CharCount: len([]rune(part)),
|
||||
TokenCount: estimateTokenCount(part),
|
||||
Metadata: map[string]any{
|
||||
"provider": enums.KnowledgeChunkProviderFixed,
|
||||
},
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Supports(contentType enums.KnowledgeDocumentContentType) bool
|
||||
Chunk(ctx context.Context, req *ChunkRequest) ([]ChunkResult, error)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
providers: make(map[string]Provider),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDefaultRegistry() *Registry {
|
||||
r := NewRegistry()
|
||||
r.Register(NewFixedProvider())
|
||||
r.Register(NewStructuredProvider())
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p Provider) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
r.providers[p.Name()] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) Provider {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return r.providers[name]
|
||||
}
|
||||
|
||||
func (r *Registry) Resolve(name string, contentType enums.KnowledgeDocumentContentType) Provider {
|
||||
if p := r.Get(name); p != nil && p.Supports(contentType) {
|
||||
return p
|
||||
}
|
||||
if p := r.Get(string(enums.KnowledgeChunkProviderStructured)); p != nil && p.Supports(contentType) {
|
||||
return p
|
||||
}
|
||||
return r.Get(string(enums.KnowledgeChunkProviderFixed))
|
||||
}
|
||||
|
||||
func (r *Registry) Chunk(ctx context.Context, req *ChunkRequest) ([]ChunkResult, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("chunk request is nil")
|
||||
}
|
||||
provider := r.Resolve(req.Options.Provider, req.ContentType)
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("chunk provider not found")
|
||||
}
|
||||
return provider.Chunk(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type structuredProvider struct{}
|
||||
|
||||
type contentBlock struct {
|
||||
Type string
|
||||
Level int
|
||||
Text string
|
||||
Title string
|
||||
SectionPath string
|
||||
}
|
||||
|
||||
func NewStructuredProvider() Provider {
|
||||
return &structuredProvider{}
|
||||
}
|
||||
|
||||
func (p *structuredProvider) Name() string {
|
||||
return string(enums.KnowledgeChunkProviderStructured)
|
||||
}
|
||||
|
||||
func (p *structuredProvider) Supports(contentType enums.KnowledgeDocumentContentType) bool {
|
||||
switch contentType {
|
||||
case enums.KnowledgeDocumentContentTypeHTML, enums.KnowledgeDocumentContentTypeMarkdown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *structuredProvider) Chunk(ctx context.Context, req *ChunkRequest) ([]ChunkResult, error) {
|
||||
content := req.Content
|
||||
if req.ContentType == enums.KnowledgeDocumentContentTypeMarkdown {
|
||||
content = string(markdown.ToHTML([]byte(content), nil, nil))
|
||||
}
|
||||
|
||||
blocks := parseStructuredBlocks(content, req.DocumentTitle)
|
||||
if len(blocks) == 0 {
|
||||
return NewFixedProvider().Chunk(ctx, req)
|
||||
}
|
||||
|
||||
results := make([]ChunkResult, 0)
|
||||
chunkNo := 0
|
||||
for _, block := range blocks {
|
||||
parts := splitPlainText(block.Text, req.Options)
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
results = append(results, ChunkResult{
|
||||
ChunkNo: chunkNo,
|
||||
Title: block.Title,
|
||||
Content: part,
|
||||
ChunkType: mapBlockType(block.Type),
|
||||
SectionPath: block.SectionPath,
|
||||
CharCount: len([]rune(part)),
|
||||
TokenCount: estimateTokenCount(part),
|
||||
Metadata: map[string]any{
|
||||
"provider": enums.KnowledgeChunkProviderStructured,
|
||||
"blockType": block.Type,
|
||||
"sectionPath": block.SectionPath,
|
||||
"sectionTitle": block.Title,
|
||||
},
|
||||
})
|
||||
chunkNo++
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return NewFixedProvider().Chunk(ctx, req)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseStructuredBlocks(content string, documentTitle string) []contentBlock {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parent := &html.Node{Type: html.ElementNode, Data: "div"}
|
||||
nodes, err := html.ParseFragment(strings.NewReader(content), parent)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var blocks []contentBlock
|
||||
headings := make([]string, 0)
|
||||
var walk func(node *html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode {
|
||||
switch node.Data {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
title := normalizeText(nodeText(node))
|
||||
if title != "" {
|
||||
level := int(node.Data[1] - '0')
|
||||
if level <= 0 {
|
||||
level = 1
|
||||
}
|
||||
headings = updateHeadingPath(headings, level, title)
|
||||
}
|
||||
return
|
||||
case "p":
|
||||
appendBlock(&blocks, "paragraph", normalizeText(nodeText(node)), currentTitle(headings, documentTitle), strings.Join(headings, " > "))
|
||||
return
|
||||
case "ul", "ol":
|
||||
appendBlock(&blocks, "list", normalizeText(listText(node)), currentTitle(headings, documentTitle), strings.Join(headings, " > "))
|
||||
return
|
||||
case "table":
|
||||
appendBlock(&blocks, "table", normalizeText(tableText(node)), currentTitle(headings, documentTitle), strings.Join(headings, " > "))
|
||||
return
|
||||
case "pre", "code":
|
||||
appendBlock(&blocks, "code", normalizeText(nodeText(node)), currentTitle(headings, documentTitle), strings.Join(headings, " > "))
|
||||
return
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
walk(node)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func appendBlock(blocks *[]contentBlock, blockType string, text string, title string, sectionPath string) {
|
||||
text = normalizeText(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if sectionPath == "" {
|
||||
sectionPath = title
|
||||
}
|
||||
*blocks = append(*blocks, contentBlock{
|
||||
Type: blockType,
|
||||
Text: text,
|
||||
Title: title,
|
||||
SectionPath: sectionPath,
|
||||
})
|
||||
}
|
||||
|
||||
func updateHeadingPath(headings []string, level int, title string) []string {
|
||||
if level <= 0 {
|
||||
level = 1
|
||||
}
|
||||
if len(headings) >= level {
|
||||
headings = headings[:level-1]
|
||||
}
|
||||
headings = append(headings, title)
|
||||
return headings
|
||||
}
|
||||
|
||||
func currentTitle(headings []string, documentTitle string) string {
|
||||
if len(headings) == 0 {
|
||||
return documentTitle
|
||||
}
|
||||
return headings[len(headings)-1]
|
||||
}
|
||||
|
||||
func mapBlockType(blockType string) enums.KnowledgeChunkType {
|
||||
switch blockType {
|
||||
case "table":
|
||||
return enums.KnowledgeChunkTypeTable
|
||||
case "code":
|
||||
return enums.KnowledgeChunkTypeCode
|
||||
default:
|
||||
return enums.KnowledgeChunkTypeText
|
||||
}
|
||||
}
|
||||
|
||||
func nodeText(node *html.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
writeNodeText(&builder, node)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func writeNodeText(builder *strings.Builder, node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Type {
|
||||
case html.TextNode:
|
||||
builder.WriteString(node.Data)
|
||||
case html.ElementNode:
|
||||
if shouldSeparate(node.Data) {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
writeNodeText(builder, child)
|
||||
}
|
||||
if node.Type == html.ElementNode && shouldSeparate(node.Data) {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSeparate(tag string) bool {
|
||||
switch tag {
|
||||
case "p", "div", "br", "li", "ul", "ol", "blockquote", "pre", "table", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func listText(node *html.Node) string {
|
||||
items := make([]string, 0)
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && child.Data == "li" {
|
||||
item := normalizeText(nodeText(child))
|
||||
if item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(items, " ")
|
||||
}
|
||||
|
||||
func tableText(node *html.Node) string {
|
||||
rows := make([]string, 0)
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "tr" {
|
||||
cells := make([]string, 0)
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
|
||||
cell := normalizeText(nodeText(child))
|
||||
if cell != "" {
|
||||
cells = append(cells, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
rows = append(rows, strings.Join(cells, " | "))
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return strings.Join(rows, " ")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package chunk
|
||||
|
||||
import "cs-agent/internal/pkg/enums"
|
||||
|
||||
type ChunkRequest struct {
|
||||
KnowledgeBaseID int64
|
||||
DocumentID int64
|
||||
DocumentTitle string
|
||||
ContentType enums.KnowledgeDocumentContentType
|
||||
Content string
|
||||
PlainText string
|
||||
Options ChunkOptions
|
||||
}
|
||||
|
||||
type ChunkOptions struct {
|
||||
Provider string
|
||||
TargetTokens int
|
||||
MaxTokens int
|
||||
OverlapTokens int
|
||||
EnableFallback bool
|
||||
}
|
||||
|
||||
type ChunkResult struct {
|
||||
ChunkNo int
|
||||
Title string
|
||||
Content string
|
||||
ChunkType enums.KnowledgeChunkType
|
||||
SectionPath string
|
||||
CharCount int
|
||||
TokenCount int
|
||||
Metadata map[string]any
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTargetTokens = 300
|
||||
defaultMaxTokens = 400
|
||||
defaultOverlapTokens = 40
|
||||
)
|
||||
|
||||
func normalizeOptions(opts ChunkOptions) ChunkOptions {
|
||||
if opts.TargetTokens <= 0 {
|
||||
opts.TargetTokens = defaultTargetTokens
|
||||
}
|
||||
if opts.MaxTokens <= 0 {
|
||||
opts.MaxTokens = defaultMaxTokens
|
||||
}
|
||||
if opts.MaxTokens < opts.TargetTokens {
|
||||
opts.MaxTokens = opts.TargetTokens
|
||||
}
|
||||
if opts.OverlapTokens < 0 {
|
||||
opts.OverlapTokens = 0
|
||||
}
|
||||
if opts.OverlapTokens == 0 {
|
||||
opts.OverlapTokens = defaultOverlapTokens
|
||||
}
|
||||
if opts.Provider == "" {
|
||||
opts.Provider = string(enums.KnowledgeChunkProviderStructured)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func normalizeText(text string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||
}
|
||||
|
||||
func estimateTokenCount(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
inWord := false
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case unicode.IsSpace(r):
|
||||
inWord = false
|
||||
case unicode.Is(unicode.Han, r):
|
||||
count++
|
||||
inWord = false
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
if !inWord {
|
||||
count++
|
||||
inWord = true
|
||||
}
|
||||
default:
|
||||
count++
|
||||
inWord = false
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return utf8.RuneCountInString(text)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func contentHash(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func splitSentences(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var sentences []string
|
||||
var builder strings.Builder
|
||||
for _, r := range text {
|
||||
builder.WriteRune(r)
|
||||
switch r {
|
||||
case '\n', '。', '!', '?', '!', '?', ';', ';':
|
||||
sentence := normalizeText(builder.String())
|
||||
if sentence != "" {
|
||||
sentences = append(sentences, sentence)
|
||||
}
|
||||
builder.Reset()
|
||||
}
|
||||
}
|
||||
if builder.Len() > 0 {
|
||||
sentence := normalizeText(builder.String())
|
||||
if sentence != "" {
|
||||
sentences = append(sentences, sentence)
|
||||
}
|
||||
}
|
||||
if len(sentences) == 0 {
|
||||
return []string{normalizeText(text)}
|
||||
}
|
||||
return sentences
|
||||
}
|
||||
|
||||
func tailTextByTokens(text string, tokenLimit int) string {
|
||||
if tokenLimit <= 0 {
|
||||
return ""
|
||||
}
|
||||
sentences := splitSentences(text)
|
||||
if len(sentences) == 0 {
|
||||
return ""
|
||||
}
|
||||
var selected []string
|
||||
total := 0
|
||||
for i := len(sentences) - 1; i >= 0; i-- {
|
||||
sentence := sentences[i]
|
||||
tokens := estimateTokenCount(sentence)
|
||||
if total > 0 && total+tokens > tokenLimit {
|
||||
break
|
||||
}
|
||||
selected = append([]string{sentence}, selected...)
|
||||
total += tokens
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(selected, " "))
|
||||
}
|
||||
|
||||
func splitPlainText(text string, opts ChunkOptions) []string {
|
||||
text = normalizeText(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
opts = normalizeOptions(opts)
|
||||
sentences := splitSentences(text)
|
||||
if len(sentences) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := make([]string, 0)
|
||||
current := make([]string, 0)
|
||||
currentTokens := 0
|
||||
|
||||
flush := func() {
|
||||
if len(current) == 0 {
|
||||
return
|
||||
}
|
||||
chunks = append(chunks, strings.Join(current, " "))
|
||||
}
|
||||
|
||||
for _, sentence := range sentences {
|
||||
sentenceTokens := estimateTokenCount(sentence)
|
||||
if sentenceTokens > opts.MaxTokens {
|
||||
if len(current) > 0 {
|
||||
flush()
|
||||
overlap := tailTextByTokens(strings.Join(current, " "), opts.OverlapTokens)
|
||||
current = nil
|
||||
currentTokens = 0
|
||||
if overlap != "" {
|
||||
current = append(current, overlap)
|
||||
currentTokens = estimateTokenCount(overlap)
|
||||
}
|
||||
}
|
||||
for _, piece := range splitLongSentence(sentence, opts.MaxTokens) {
|
||||
piece = normalizeText(piece)
|
||||
if piece != "" {
|
||||
chunks = append(chunks, piece)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentTokens > 0 && currentTokens+sentenceTokens > opts.MaxTokens {
|
||||
flush()
|
||||
overlap := tailTextByTokens(strings.Join(current, " "), opts.OverlapTokens)
|
||||
current = nil
|
||||
currentTokens = 0
|
||||
if overlap != "" {
|
||||
current = append(current, overlap)
|
||||
currentTokens = estimateTokenCount(overlap)
|
||||
}
|
||||
}
|
||||
|
||||
current = append(current, sentence)
|
||||
currentTokens += sentenceTokens
|
||||
}
|
||||
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
func splitLongSentence(text string, maxTokens int) []string {
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if maxTokens <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
window := maxTokens * 2
|
||||
if window < 50 {
|
||||
window = 50
|
||||
}
|
||||
var result []string
|
||||
for start := 0; start < len(runes); start += window {
|
||||
end := start + window
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
part := normalizeText(string(runes[start:end]))
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
ragchunk "cs-agent/internal/ai/rag/chunk"
|
||||
"cs-agent/internal/ai/rag/vectordb"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type ChunkingConfig struct {
|
||||
Provider string
|
||||
TargetTokens int
|
||||
MaxTokens int
|
||||
OverlapTokens int
|
||||
EnableFallback bool
|
||||
}
|
||||
|
||||
type index struct {
|
||||
chunkConfig ChunkingConfig
|
||||
registry *ragchunk.Registry
|
||||
}
|
||||
|
||||
const knowledgeCollectionName = "knowledge_chunks"
|
||||
|
||||
var Index = &index{
|
||||
chunkConfig: ChunkingConfig{
|
||||
Provider: string(enums.KnowledgeChunkProviderStructured),
|
||||
TargetTokens: 300,
|
||||
MaxTokens: 400,
|
||||
OverlapTokens: 40,
|
||||
EnableFallback: true,
|
||||
},
|
||||
registry: ragchunk.NewDefaultRegistry(),
|
||||
}
|
||||
|
||||
func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
|
||||
document := repositories.KnowledgeDocumentRepository.Get(sqls.DB(), documentID)
|
||||
if document == nil {
|
||||
return fmt.Errorf("document not found: %d", documentID)
|
||||
}
|
||||
return s.IndexDocument(ctx, document)
|
||||
}
|
||||
|
||||
func (s *index) IndexDocument(ctx context.Context, document *models.KnowledgeDocument) error {
|
||||
start := time.Now()
|
||||
if err := s.markDocumentIndexPending(document.ID); err != nil {
|
||||
slog.Error("Failed to mark knowledge document index as pending", "document_id", document.ID, "error", err)
|
||||
}
|
||||
|
||||
fail := func(err error) error {
|
||||
if updateErr := s.markDocumentIndexFailed(document.ID, err); updateErr != nil {
|
||||
slog.Error("Failed to mark knowledge document index as failed", "document_id", document.ID, "error", updateErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO 这里每次都查询下知识库不太友好
|
||||
knowledgeBase := repositories.KnowledgeBaseRepository.Get(sqls.DB(), document.KnowledgeBaseID)
|
||||
if knowledgeBase == nil {
|
||||
return fail(fmt.Errorf("knowledge base not found: %d", document.KnowledgeBaseID))
|
||||
}
|
||||
|
||||
existingChunks := repositories.KnowledgeChunkRepository.FindByDocumentID(sqls.DB(), document.ID)
|
||||
|
||||
chunks, err := s.registry.Chunk(ctx, &ragchunk.ChunkRequest{
|
||||
KnowledgeBaseID: document.KnowledgeBaseID,
|
||||
DocumentID: document.ID,
|
||||
DocumentTitle: document.Title,
|
||||
ContentType: document.ContentType,
|
||||
Content: document.Content,
|
||||
PlainText: ExtractPlainText(document.Content, document.ContentType),
|
||||
Options: ragchunk.ChunkOptions{
|
||||
Provider: firstNonEmptyString(knowledgeBase.ChunkProvider, s.chunkConfig.Provider),
|
||||
TargetTokens: firstPositiveInt(knowledgeBase.ChunkTargetTokens, s.chunkConfig.TargetTokens),
|
||||
MaxTokens: firstPositiveInt(knowledgeBase.ChunkMaxTokens, s.chunkConfig.MaxTokens),
|
||||
OverlapTokens: firstPositiveInt(knowledgeBase.ChunkOverlapTokens, s.chunkConfig.OverlapTokens),
|
||||
EnableFallback: s.chunkConfig.EnableFallback,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("failed to chunk document: %w", err))
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return fail(fmt.Errorf("no chunks generated from document"))
|
||||
}
|
||||
|
||||
collectionName := s.getCollectionName()
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fail(fmt.Errorf("vectordb provider not initialized"))
|
||||
}
|
||||
|
||||
if _, err := ai.Embedding.GetModel(ctx); err != nil {
|
||||
return fail(fmt.Errorf("failed to get embedding model: %w", err))
|
||||
}
|
||||
|
||||
existingVectorIDs := make([]string, 0, len(existingChunks))
|
||||
for _, chunk := range existingChunks {
|
||||
if strs.IsNotBlank(chunk.VectorID) {
|
||||
existingVectorIDs = append(existingVectorIDs, chunk.VectorID)
|
||||
}
|
||||
}
|
||||
|
||||
vectors := make([]vectordb.Vector, 0, len(chunks))
|
||||
chunkModels := make([]models.KnowledgeChunk, 0, len(chunks))
|
||||
dimension := 0
|
||||
|
||||
for i, chunk := range chunks {
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
slog.Error("Failed to generate embedding for chunk", "document_id", document.ID, "chunk_index", i, "error", err)
|
||||
return fail(fmt.Errorf("failed to generate embedding for chunk %d: %w", i, err))
|
||||
}
|
||||
if dimension == 0 {
|
||||
dimension = embeddingResult.Dimension
|
||||
}
|
||||
|
||||
chunkID := buildKnowledgeChunkVectorID(knowledgeBase.ID, document.ID, chunk.ChunkNo)
|
||||
providerName := ""
|
||||
if chunk.Metadata != nil {
|
||||
if value, ok := chunk.Metadata["provider"].(string); ok {
|
||||
providerName = value
|
||||
}
|
||||
}
|
||||
chunkModel := models.KnowledgeChunk{
|
||||
KnowledgeBaseID: knowledgeBase.ID,
|
||||
DocumentID: document.ID,
|
||||
ChunkNo: chunk.ChunkNo,
|
||||
Title: chunk.Title,
|
||||
Content: chunk.Content,
|
||||
ContentHash: buildChunkContentHash(chunk.Content),
|
||||
CharCount: chunk.CharCount,
|
||||
TokenCount: chunk.TokenCount,
|
||||
ChunkType: string(chunk.ChunkType),
|
||||
SectionPath: chunk.SectionPath,
|
||||
Provider: providerName,
|
||||
VectorID: chunkID,
|
||||
Status: enums.StatusOk,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
chunkModels = append(chunkModels, chunkModel)
|
||||
|
||||
vectors = append(vectors, vectordb.Vector{
|
||||
ID: chunkID,
|
||||
Vector: embeddingResult.Vector,
|
||||
Payload: vectordb.ChunkPayload{
|
||||
KnowledgeBaseID: knowledgeBase.ID,
|
||||
DocumentID: document.ID,
|
||||
DocumentTitle: document.Title,
|
||||
ChunkNo: chunk.ChunkNo,
|
||||
ChunkType: string(chunk.ChunkType),
|
||||
SectionPath: chunk.SectionPath,
|
||||
Content: chunk.Content,
|
||||
Title: chunk.Title,
|
||||
Provider: providerName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(vectors) == 0 {
|
||||
return fail(fmt.Errorf("no vectors generated"))
|
||||
}
|
||||
|
||||
collectionInfo, err := provider.GetCollection(ctx, collectionName)
|
||||
if err != nil || collectionInfo == nil {
|
||||
if dimension <= 0 {
|
||||
return fail(fmt.Errorf("invalid embedding dimension: %d", dimension))
|
||||
}
|
||||
if err := provider.CreateCollection(ctx, collectionName, dimension); err != nil {
|
||||
return fail(fmt.Errorf("failed to create collection: %w", err))
|
||||
}
|
||||
slog.Info("Created collection for knowledge base", "collection", collectionName, "dimension", dimension)
|
||||
}
|
||||
|
||||
if len(existingVectorIDs) > 0 {
|
||||
if err := provider.DeleteVectors(ctx, collectionName, existingVectorIDs); err != nil {
|
||||
return fail(fmt.Errorf("failed to delete old vectors: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := provider.UpsertVectors(ctx, collectionName, vectors); err != nil {
|
||||
return fail(fmt.Errorf("failed to upsert vectors: %w", err))
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Where("document_id = ?", document.ID).Delete(&models.KnowledgeChunk{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, chunk := range chunkModels {
|
||||
if err := ctx.Tx.Create(&chunk).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fail(fmt.Errorf("failed to save chunks: %w", err))
|
||||
}
|
||||
|
||||
if err := s.markDocumentIndexIndexed(document.ID); err != nil {
|
||||
slog.Error("Failed to mark knowledge document index as indexed", "document_id", document.ID, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("Document indexed successfully",
|
||||
slog.Any("document_id", document.ID),
|
||||
slog.Any("chunks_count", len(chunks)),
|
||||
slog.Any("vectors_count", len(vectors)),
|
||||
slog.Any("time_taken", time.Since(start).String()),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *index) IndexFAQByID(ctx context.Context, faqID int64) error {
|
||||
faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), faqID)
|
||||
if faq == nil {
|
||||
return fmt.Errorf("faq not found: %d", faqID)
|
||||
}
|
||||
if err := s.markFAQIndexPending(faq.ID); err != nil {
|
||||
slog.Error("Failed to mark knowledge faq index as pending", "faq_id", faq.ID, "error", err)
|
||||
}
|
||||
fail := func(err error) error {
|
||||
if updateErr := s.markFAQIndexFailed(faq.ID, err); updateErr != nil {
|
||||
slog.Error("Failed to mark knowledge faq index as failed", "faq_id", faq.ID, "error", updateErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
knowledgeBase := repositories.KnowledgeBaseRepository.Get(sqls.DB(), faq.KnowledgeBaseID)
|
||||
if knowledgeBase == nil {
|
||||
return fail(fmt.Errorf("knowledge base not found: %d", faq.KnowledgeBaseID))
|
||||
}
|
||||
if knowledgeBase.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) {
|
||||
return fail(fmt.Errorf("knowledge base %d is not faq type", knowledgeBase.ID))
|
||||
}
|
||||
existingChunks := repositories.KnowledgeChunkRepository.FindByFaqID(sqls.DB(), faq.ID)
|
||||
content := buildFAQChunkContent(faq)
|
||||
if content == "" {
|
||||
return fail(fmt.Errorf("faq content is empty"))
|
||||
}
|
||||
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fail(fmt.Errorf("vectordb provider not initialized"))
|
||||
}
|
||||
if _, err := ai.Embedding.GetModel(ctx); err != nil {
|
||||
return fail(fmt.Errorf("failed to get embedding model: %w", err))
|
||||
}
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, content)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("failed to generate embedding for faq %d: %w", faq.ID, err))
|
||||
}
|
||||
|
||||
chunkID := buildKnowledgeFAQChunkVectorID(knowledgeBase.ID, faq.ID, 0)
|
||||
chunkModel := models.KnowledgeChunk{
|
||||
KnowledgeBaseID: knowledgeBase.ID,
|
||||
FaqID: faq.ID,
|
||||
ChunkNo: 0,
|
||||
Title: faq.Question,
|
||||
Content: content,
|
||||
ContentHash: buildChunkContentHash(content),
|
||||
CharCount: len([]rune(content)),
|
||||
TokenCount: len([]rune(content)) / 2,
|
||||
ChunkType: string(enums.KnowledgeChunkTypeFAQ),
|
||||
Provider: string(enums.KnowledgeChunkProviderFAQ),
|
||||
VectorID: chunkID,
|
||||
Status: enums.StatusOk,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
collectionName := s.getCollectionName()
|
||||
collectionInfo, err := provider.GetCollection(ctx, collectionName)
|
||||
if err != nil || collectionInfo == nil {
|
||||
if err := provider.CreateCollection(ctx, collectionName, embeddingResult.Dimension); err != nil {
|
||||
return fail(fmt.Errorf("failed to create collection: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
existingVectorIDs := make([]string, 0, len(existingChunks))
|
||||
for _, chunk := range existingChunks {
|
||||
if strs.IsNotBlank(chunk.VectorID) {
|
||||
existingVectorIDs = append(existingVectorIDs, chunk.VectorID)
|
||||
}
|
||||
}
|
||||
if len(existingVectorIDs) > 0 {
|
||||
if err := provider.DeleteVectors(ctx, collectionName, existingVectorIDs); err != nil {
|
||||
return fail(fmt.Errorf("failed to delete old vectors: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := provider.UpsertVectors(ctx, collectionName, []vectordb.Vector{{
|
||||
ID: chunkID,
|
||||
Vector: embeddingResult.Vector,
|
||||
Payload: vectordb.ChunkPayload{
|
||||
KnowledgeBaseID: knowledgeBase.ID,
|
||||
FaqID: faq.ID,
|
||||
FaqQuestion: faq.Question,
|
||||
ChunkNo: 0,
|
||||
ChunkType: string(enums.KnowledgeChunkTypeFAQ),
|
||||
Content: content,
|
||||
Title: faq.Question,
|
||||
Provider: string(enums.KnowledgeChunkProviderFAQ),
|
||||
},
|
||||
}}); err != nil {
|
||||
return fail(fmt.Errorf("failed to upsert vectors: %w", err))
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Where("faq_id = ?", faq.ID).Delete(&models.KnowledgeChunk{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Tx.Create(&chunkModel).Error
|
||||
}); err != nil {
|
||||
return fail(fmt.Errorf("failed to save faq chunk: %w", err))
|
||||
}
|
||||
if err := s.markFAQIndexIndexed(faq.ID); err != nil {
|
||||
slog.Error("Failed to mark knowledge faq index as indexed", "faq_id", faq.ID, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *index) RemoveDocumentIndex(ctx context.Context, documentID int64) error {
|
||||
document := repositories.KnowledgeDocumentRepository.Get(sqls.DB(), documentID)
|
||||
if document == nil {
|
||||
return nil
|
||||
}
|
||||
chunks := repositories.KnowledgeChunkRepository.Find(sqls.DB(), sqls.NewCnd().Eq("document_id", documentID))
|
||||
return s.removeDocumentIndexByChunks(ctx, document.KnowledgeBaseID, documentID, chunks)
|
||||
}
|
||||
|
||||
func (s *index) RemoveDocumentIndexFromKnowledgeBase(ctx context.Context, knowledgeBaseID int64, documentID int64) error {
|
||||
chunks := repositories.KnowledgeChunkRepository.Find(sqls.DB(), sqls.NewCnd().Eq("document_id", documentID))
|
||||
return s.removeDocumentIndexByChunks(ctx, knowledgeBaseID, documentID, chunks)
|
||||
}
|
||||
|
||||
func (s *index) RemoveDocumentIndexByChunkModels(ctx context.Context, knowledgeBaseID int64, documentID int64, chunks []models.KnowledgeChunk) error {
|
||||
return s.removeDocumentIndexByChunks(ctx, knowledgeBaseID, documentID, chunks)
|
||||
}
|
||||
|
||||
func (s *index) removeDocumentIndexByChunks(ctx context.Context, knowledgeBaseID int64, documentID int64, chunks []models.KnowledgeChunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
collectionName := s.getCollectionName()
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
|
||||
vectorIDs := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
if chunk.VectorID != "" {
|
||||
vectorIDs = append(vectorIDs, chunk.VectorID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(vectorIDs) > 0 {
|
||||
if err := provider.DeleteVectors(ctx, collectionName, vectorIDs); err != nil {
|
||||
slog.Error("Failed to delete vectors", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return ctx.Tx.Where("document_id = ?", documentID).Delete(&models.KnowledgeChunk{}).Error
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to delete chunks: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Document index removed", "document_id", documentID, "chunks_removed", len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *index) RemoveFAQIndex(ctx context.Context, faqID int64) error {
|
||||
faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), faqID)
|
||||
if faq == nil {
|
||||
return nil
|
||||
}
|
||||
chunks := repositories.KnowledgeChunkRepository.FindByFaqID(sqls.DB(), faqID)
|
||||
return s.removeFAQIndexByChunks(ctx, faq.KnowledgeBaseID, faqID, chunks)
|
||||
}
|
||||
|
||||
func (s *index) RemoveFAQIndexByChunkModels(ctx context.Context, knowledgeBaseID int64, faqID int64, chunks []models.KnowledgeChunk) error {
|
||||
return s.removeFAQIndexByChunks(ctx, knowledgeBaseID, faqID, chunks)
|
||||
}
|
||||
|
||||
func (s *index) removeFAQIndexByChunks(ctx context.Context, knowledgeBaseID int64, faqID int64, chunks []models.KnowledgeChunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
collectionName := s.getCollectionName()
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
vectorIDs := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
if chunk.VectorID != "" {
|
||||
vectorIDs = append(vectorIDs, chunk.VectorID)
|
||||
}
|
||||
}
|
||||
if len(vectorIDs) > 0 {
|
||||
if err := provider.DeleteVectors(ctx, collectionName, vectorIDs); err != nil {
|
||||
slog.Error("Failed to delete faq vectors", "error", err)
|
||||
}
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return ctx.Tx.Where("faq_id = ?", faqID).Delete(&models.KnowledgeChunk{}).Error
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to delete faq chunks: %w", err)
|
||||
}
|
||||
slog.Info("FAQ index removed", "faq_id", faqID, "chunks_removed", len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *index) getCollectionName() string {
|
||||
return knowledgeCollectionName
|
||||
}
|
||||
|
||||
func buildKnowledgeChunkVectorID(knowledgeBaseID int64, documentID int64, chunkNo int) string {
|
||||
raw := fmt.Sprintf("kb:%d:doc:%d:chunk:%d", knowledgeBaseID, documentID, chunkNo)
|
||||
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(raw)).String()
|
||||
}
|
||||
|
||||
func buildKnowledgeFAQChunkVectorID(knowledgeBaseID int64, faqID int64, chunkNo int) string {
|
||||
raw := fmt.Sprintf("kb:%d:faq:%d:chunk:%d", knowledgeBaseID, faqID, chunkNo)
|
||||
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(raw)).String()
|
||||
}
|
||||
|
||||
func buildChunkContentHash(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func firstPositiveInt(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *index) EnsureCollection(ctx context.Context) error {
|
||||
dimension, err := ai.Embedding.GetDimension(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get embedding dimension: %w", err)
|
||||
}
|
||||
|
||||
collectionName := s.getCollectionName()
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
|
||||
existing, err := provider.GetCollection(ctx, collectionName)
|
||||
if err == nil && existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.CreateCollection(ctx, collectionName, dimension)
|
||||
}
|
||||
|
||||
func (s *index) RebuildKnowledgeBaseIndex(ctx context.Context, knowledgeBaseID int64) error {
|
||||
knowledgeBase := repositories.KnowledgeBaseRepository.Get(sqls.DB(), knowledgeBaseID)
|
||||
if knowledgeBase == nil {
|
||||
return fmt.Errorf("knowledge base not found: %d", knowledgeBaseID)
|
||||
}
|
||||
|
||||
if err := s.resetKnowledgeBaseIndexStorage(ctx, knowledgeBaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
if knowledgeBase.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) {
|
||||
faqs := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().
|
||||
Eq("knowledge_base_id", knowledgeBaseID).
|
||||
Where("status != ?", enums.StatusDeleted))
|
||||
if len(faqs) == 0 {
|
||||
slog.Info("No faqs found in knowledge base, nothing to rebuild", "knowledge_base_id", knowledgeBaseID)
|
||||
return nil
|
||||
}
|
||||
slog.Info("Rebuilding faq knowledge base index", "knowledge_base_id", knowledgeBaseID, "faq_count", len(faqs))
|
||||
for _, faq := range faqs {
|
||||
if err := s.IndexFAQByID(ctx, faq.ID); err != nil {
|
||||
slog.Error("Failed to index faq", "faq_id", faq.ID, "error", err)
|
||||
failedCount++
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
documents := repositories.KnowledgeDocumentRepository.Find(sqls.DB(), sqls.NewCnd().
|
||||
Eq("knowledge_base_id", knowledgeBaseID).
|
||||
Where("status != ?", enums.StatusDeleted))
|
||||
if len(documents) == 0 {
|
||||
slog.Info("No documents found in knowledge base, nothing to rebuild", "knowledge_base_id", knowledgeBaseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
documentIDs := make([]int64, 0, len(documents))
|
||||
for _, doc := range documents {
|
||||
documentIDs = append(documentIDs, doc.ID)
|
||||
}
|
||||
if err := s.markKnowledgeBaseDocumentsIndexPending(knowledgeBaseID, documentIDs); err != nil {
|
||||
slog.Error("Failed to mark knowledge base documents index as pending", "knowledge_base_id", knowledgeBaseID, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("Rebuilding knowledge base index", "knowledge_base_id", knowledgeBaseID, "document_count", len(documents))
|
||||
for _, doc := range documents {
|
||||
if err := s.IndexDocumentByID(ctx, doc.ID); err != nil {
|
||||
slog.Error("Failed to index document", "document_id", doc.ID, "error", err)
|
||||
failedCount++
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Knowledge base index rebuild completed",
|
||||
"knowledge_base_id", knowledgeBaseID,
|
||||
"success_count", successCount,
|
||||
"failed_count", failedCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFAQChunkContent(faq *models.KnowledgeFAQ) string {
|
||||
if faq == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{fmt.Sprintf("问题:%s", faq.Question)}
|
||||
var similarQuestions []string
|
||||
if faq.SimilarQuestions != "" {
|
||||
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &similarQuestions)
|
||||
}
|
||||
if len(similarQuestions) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("相似问:%s", joinSimilarQuestions(similarQuestions)))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("回答:%s", faq.Answer))
|
||||
content := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += part
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func joinSimilarQuestions(items []string) string {
|
||||
result := ""
|
||||
for _, item := range items {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if result != "" {
|
||||
result += ";"
|
||||
}
|
||||
result += item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *index) markDocumentIndexPending(documentID int64) error {
|
||||
return repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), documentID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *index) markDocumentIndexIndexed(documentID int64) error {
|
||||
now := time.Now()
|
||||
return repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), documentID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusIndexed,
|
||||
"indexed_at": &now,
|
||||
"index_error": "",
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *index) markDocumentIndexFailed(documentID int64, err error) error {
|
||||
return repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), documentID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusFailed,
|
||||
"index_error": truncateIndexError(err),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *index) markKnowledgeBaseDocumentsIndexPending(knowledgeBaseID int64, documentIDs []int64) error {
|
||||
if len(documentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return sqls.DB().Model(&models.KnowledgeDocument{}).
|
||||
Where("knowledge_base_id = ?", knowledgeBaseID).
|
||||
Where("id IN ?", documentIDs).
|
||||
Updates(map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *index) markFAQIndexPending(faqID int64) error {
|
||||
return repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faqID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *index) markFAQIndexIndexed(faqID int64) error {
|
||||
now := time.Now()
|
||||
return repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faqID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusIndexed,
|
||||
"indexed_at": &now,
|
||||
"index_error": "",
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *index) markFAQIndexFailed(faqID int64, err error) error {
|
||||
return repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faqID, map[string]any{
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusFailed,
|
||||
"index_error": truncateIndexError(err),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func truncateIndexError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
if len(message) <= 1000 {
|
||||
return message
|
||||
}
|
||||
return message[:1000]
|
||||
}
|
||||
|
||||
func (s *index) resetKnowledgeBaseIndexStorage(ctx context.Context, knowledgeBaseID int64) error {
|
||||
collectionName := s.getCollectionName()
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
|
||||
chunks := repositories.KnowledgeChunkRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID))
|
||||
vectorIDs := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
if strs.IsNotBlank(chunk.VectorID) {
|
||||
vectorIDs = append(vectorIDs, chunk.VectorID)
|
||||
}
|
||||
}
|
||||
if len(vectorIDs) > 0 {
|
||||
if err := provider.DeleteVectors(ctx, collectionName, vectorIDs); err != nil {
|
||||
return fmt.Errorf("failed to delete vectors for knowledge base %d before rebuild: %w", knowledgeBaseID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return ctx.Tx.Where("knowledge_base_id = ?", knowledgeBaseID).Delete(&models.KnowledgeChunk{}).Error
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to clear chunks before rebuild: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Knowledge base index storage reset",
|
||||
"knowledge_base_id", knowledgeBaseID,
|
||||
"collection", collectionName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type rerank struct{}
|
||||
|
||||
var Rerank = &rerank{}
|
||||
|
||||
func (s *rerank) Rerank(ctx context.Context, query string, documents []string, topN int) ([]RerankResult, error) {
|
||||
if len(documents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if topN <= 0 {
|
||||
topN = len(documents)
|
||||
}
|
||||
|
||||
results, err := s.callRerankAPI(ctx, query, documents, topN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *rerank) callRerankAPI(ctx context.Context, query string, documents []string, topN int) ([]RerankResult, error) {
|
||||
config, err := ai.GetEnabledAIConfig(enums.AIModelTypeRerank)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := RerankRequest{
|
||||
Model: config.ModelName,
|
||||
Query: query,
|
||||
Documents: documents,
|
||||
TopN: topN,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.BaseURL+"/v1/rerank", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(config.TimeoutMS) * time.Millisecond,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call rerank API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var rerankResp RerankResponse
|
||||
if err := json.Unmarshal(body, &rerankResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
results := make([]RerankResult, 0, len(rerankResp.Results))
|
||||
for _, r := range rerankResp.Results {
|
||||
results = append(results, RerankResult{
|
||||
Index: r.Index,
|
||||
RelevanceScore: r.RelevanceScore,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *rerank) RerankResults(ctx context.Context, query string, results []RetrieveResult, topN int) ([]RetrieveResult, error) {
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if topN <= 0 {
|
||||
topN = len(results)
|
||||
}
|
||||
|
||||
documents := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
documents = append(documents, r.Content)
|
||||
}
|
||||
|
||||
rerankResults, err := s.Rerank(ctx, query, documents, topN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rerankedResults := make([]RetrieveResult, 0, len(rerankResults))
|
||||
for _, rr := range rerankResults {
|
||||
if rr.Index < len(results) {
|
||||
result := results[rr.Index]
|
||||
result.Score = float32(rr.RelevanceScore)
|
||||
rerankedResults = append(rerankedResults, result)
|
||||
}
|
||||
}
|
||||
|
||||
return rerankedResults, nil
|
||||
}
|
||||
|
||||
func (s *rerank) SimpleRerank(query string, results []RetrieveResult, topN int) []RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if topN > 0 && len(results) > topN {
|
||||
return results[:topN]
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/ai/rag/vectordb"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
)
|
||||
|
||||
type retrieve struct {
|
||||
}
|
||||
|
||||
var Retrieve = &retrieve{}
|
||||
|
||||
func (s *retrieve) Retrieve(ctx context.Context, req RetrieveRequest) ([]RetrieveResult, error) {
|
||||
results, _, err := s.RetrieveWithTrace(ctx, req)
|
||||
return results, err
|
||||
}
|
||||
|
||||
type RetrieveTrace struct {
|
||||
EmbeddingMs int64
|
||||
VectorSearchMs int64
|
||||
HydrateMs int64
|
||||
}
|
||||
|
||||
func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) ([]RetrieveResult, *RetrieveTrace, error) {
|
||||
trace := &RetrieveTrace{}
|
||||
if req.Query == "" {
|
||||
return nil, trace, nil
|
||||
}
|
||||
knowledgeBaseIDs := normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
|
||||
if len(knowledgeBaseIDs) == 0 {
|
||||
return nil, trace, nil
|
||||
}
|
||||
|
||||
retrievableKnowledgeBases := s.loadRetrievableKnowledgeBases(knowledgeBaseIDs)
|
||||
if len(retrievableKnowledgeBases) == 0 {
|
||||
slog.Info("Skip retrieve for non-enabled knowledge bases",
|
||||
"knowledge_base_ids", fmt.Sprint(knowledgeBaseIDs))
|
||||
return nil, trace, nil
|
||||
}
|
||||
|
||||
embeddingStartedAt := time.Now()
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, req.Query)
|
||||
trace.EmbeddingMs = time.Since(embeddingStartedAt).Milliseconds()
|
||||
if err != nil {
|
||||
return nil, trace, fmt.Errorf("failed to generate query embedding: %w", err)
|
||||
}
|
||||
|
||||
collectionName := knowledgeCollectionName
|
||||
provider := vectordb.GetProvider()
|
||||
if provider == nil {
|
||||
return nil, trace, fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
|
||||
searchResults := make([]vectordb.SearchResult, 0)
|
||||
vectorSearchStartedAt := time.Now()
|
||||
for _, knowledgeBase := range retrievableKnowledgeBases {
|
||||
topK, scoreThreshold := resolveKnowledgeBaseSearchOptions(req, &knowledgeBase)
|
||||
kbResults, searchErr := provider.Search(ctx, &vectordb.SearchRequest{
|
||||
CollectionName: collectionName,
|
||||
Vector: embeddingResult.Vector,
|
||||
TopK: topK,
|
||||
ScoreThreshold: scoreThreshold,
|
||||
Filter: &vectordb.SearchFilter{
|
||||
KnowledgeBaseIDs: []int64{knowledgeBase.ID},
|
||||
},
|
||||
})
|
||||
if searchErr != nil {
|
||||
slog.Error("Failed to search vectors",
|
||||
"knowledge_base_id", knowledgeBase.ID,
|
||||
"error", searchErr)
|
||||
trace.VectorSearchMs = time.Since(vectorSearchStartedAt).Milliseconds()
|
||||
return nil, trace, fmt.Errorf("failed to search vectors: %w", searchErr)
|
||||
}
|
||||
if len(kbResults) == 0 && scoreThreshold > 0 {
|
||||
s.logEmptySearchDiagnostics(ctx, provider, collectionName, embeddingResult.Vector, topK, scoreThreshold, []int64{knowledgeBase.ID}, req)
|
||||
}
|
||||
searchResults = append(searchResults, kbResults...)
|
||||
}
|
||||
trace.VectorSearchMs = time.Since(vectorSearchStartedAt).Milliseconds()
|
||||
|
||||
if len(searchResults) == 0 {
|
||||
return nil, trace, nil
|
||||
}
|
||||
sort.SliceStable(searchResults, func(i, j int) bool {
|
||||
if searchResults[i].Score == searchResults[j].Score {
|
||||
return searchResults[i].ID < searchResults[j].ID
|
||||
}
|
||||
return searchResults[i].Score > searchResults[j].Score
|
||||
})
|
||||
|
||||
results := make([]RetrieveResult, 0, len(searchResults))
|
||||
hydrateStartedAt := time.Now()
|
||||
vectorIDs := make([]string, 0, len(searchResults))
|
||||
for _, sr := range searchResults {
|
||||
if strings.TrimSpace(sr.ID) == "" {
|
||||
continue
|
||||
}
|
||||
vectorIDs = append(vectorIDs, sr.ID)
|
||||
}
|
||||
chunks := repositories.KnowledgeChunkRepository.FindByVectorIDs(sqls.DB(), vectorIDs)
|
||||
chunkByVectorID := make(map[string]*models.KnowledgeChunk, len(chunks))
|
||||
documentIDs := make([]int64, 0)
|
||||
faqIDs := make([]int64, 0)
|
||||
documentSeen := make(map[int64]struct{})
|
||||
faqSeen := make(map[int64]struct{})
|
||||
for i := range chunks {
|
||||
chunk := &chunks[i]
|
||||
chunkByVectorID[chunk.VectorID] = chunk
|
||||
if chunk.DocumentID > 0 {
|
||||
if _, ok := documentSeen[chunk.DocumentID]; !ok {
|
||||
documentSeen[chunk.DocumentID] = struct{}{}
|
||||
documentIDs = append(documentIDs, chunk.DocumentID)
|
||||
}
|
||||
}
|
||||
if chunk.FaqID > 0 {
|
||||
if _, ok := faqSeen[chunk.FaqID]; !ok {
|
||||
faqSeen[chunk.FaqID] = struct{}{}
|
||||
faqIDs = append(faqIDs, chunk.FaqID)
|
||||
}
|
||||
}
|
||||
}
|
||||
documents := repositories.KnowledgeDocumentRepository.FindByIDs(sqls.DB(), documentIDs)
|
||||
documentByID := make(map[int64]*models.KnowledgeDocument, len(documents))
|
||||
for i := range documents {
|
||||
document := &documents[i]
|
||||
documentByID[document.ID] = document
|
||||
}
|
||||
faqs := repositories.KnowledgeFAQRepository.FindByIDs(sqls.DB(), faqIDs)
|
||||
faqByID := make(map[int64]*models.KnowledgeFAQ, len(faqs))
|
||||
for i := range faqs {
|
||||
faq := &faqs[i]
|
||||
faqByID[faq.ID] = faq
|
||||
}
|
||||
for _, sr := range searchResults {
|
||||
chunk := chunkByVectorID[sr.ID]
|
||||
if chunk == nil || chunk.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
|
||||
documentTitle := ""
|
||||
faqQuestion := ""
|
||||
if chunk.DocumentID > 0 {
|
||||
document := documentByID[chunk.DocumentID]
|
||||
if document == nil || document.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
documentTitle = document.Title
|
||||
}
|
||||
if chunk.FaqID > 0 {
|
||||
faq := faqByID[chunk.FaqID]
|
||||
if faq == nil || faq.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
faqQuestion = faq.Question
|
||||
}
|
||||
|
||||
results = append(results, RetrieveResult{
|
||||
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
||||
ChunkID: chunk.ID,
|
||||
DocumentID: chunk.DocumentID,
|
||||
DocumentTitle: documentTitle,
|
||||
FaqID: chunk.FaqID,
|
||||
FaqQuestion: faqQuestion,
|
||||
ChunkNo: chunk.ChunkNo,
|
||||
Title: chunk.Title,
|
||||
SectionPath: chunk.SectionPath,
|
||||
Content: chunk.Content,
|
||||
Score: sr.Score,
|
||||
ChunkType: extractChunkType(sr.Payload),
|
||||
})
|
||||
}
|
||||
trace.HydrateMs = time.Since(hydrateStartedAt).Milliseconds()
|
||||
|
||||
return results, trace, nil
|
||||
}
|
||||
|
||||
func extractChunkType(payload vectordb.ChunkPayload) string {
|
||||
if payload.ChunkType != "" {
|
||||
return payload.ChunkType
|
||||
}
|
||||
return string(enums.KnowledgeChunkTypeText)
|
||||
}
|
||||
|
||||
func (s *retrieve) logEmptySearchDiagnostics(ctx context.Context, provider vectordb.Provider, collectionName string, vector []float32, topK int, scoreThreshold float32, knowledgeBaseIDs []int64, req RetrieveRequest) {
|
||||
rawResults, err := provider.Search(ctx, &vectordb.SearchRequest{
|
||||
CollectionName: collectionName,
|
||||
Vector: vector,
|
||||
TopK: topK,
|
||||
ScoreThreshold: 0,
|
||||
Filter: &vectordb.SearchFilter{
|
||||
KnowledgeBaseIDs: knowledgeBaseIDs,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("Knowledge retrieve diagnostics failed",
|
||||
"knowledge_base_ids", fmt.Sprint(knowledgeBaseIDs),
|
||||
"collection", collectionName,
|
||||
"query", truncateForLog(req.Query, 80),
|
||||
"score_threshold", scoreThreshold,
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
if len(rawResults) == 0 {
|
||||
slog.Info("Knowledge retrieve returned no candidates even without threshold",
|
||||
"knowledge_base_ids", fmt.Sprint(knowledgeBaseIDs),
|
||||
"collection", collectionName,
|
||||
"query", truncateForLog(req.Query, 80),
|
||||
"score_threshold", scoreThreshold)
|
||||
return
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, len(rawResults))
|
||||
for _, item := range rawResults {
|
||||
candidates = append(candidates, fmt.Sprintf("%s:%.4f", item.ID, item.Score))
|
||||
}
|
||||
|
||||
slog.Info("Knowledge retrieve filtered all candidates by score threshold",
|
||||
"knowledge_base_ids", fmt.Sprint(knowledgeBaseIDs),
|
||||
"collection", collectionName,
|
||||
"query", truncateForLog(req.Query, 80),
|
||||
"score_threshold", scoreThreshold,
|
||||
"top_candidates", strings.Join(candidates, ","))
|
||||
}
|
||||
|
||||
func truncateForLog(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func (s *retrieve) RetrieveWithRerank(ctx context.Context, req RetrieveRequest, rerankLimit int) ([]RetrieveResult, error) {
|
||||
results, err := s.Retrieve(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results) <= rerankLimit {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
rerankedResults, err := s.rerank(ctx, req.Query, results, rerankLimit)
|
||||
if err != nil {
|
||||
slog.Warn("Rerank failed, returning original results", "error", err)
|
||||
if len(results) > rerankLimit {
|
||||
return results[:rerankLimit], nil
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
return rerankedResults, nil
|
||||
}
|
||||
|
||||
func (s *retrieve) rerank(ctx context.Context, query string, results []RetrieveResult, limit int) ([]RetrieveResult, error) {
|
||||
return Rerank.RerankResults(ctx, query, results, limit)
|
||||
}
|
||||
|
||||
func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int) []RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedResults := normalizeContextResults(results)
|
||||
selected := make([]RetrieveResult, 0, len(normalizedResults))
|
||||
totalTokens := 0
|
||||
documentUsage := make(map[int64]int)
|
||||
|
||||
for _, item := range normalizedResults {
|
||||
if documentUsage[item.DocumentID] >= 2 {
|
||||
continue
|
||||
}
|
||||
chunkText := buildContextChunkText(item)
|
||||
estimatedTokens := len(chunkText) / 2
|
||||
if totalTokens+estimatedTokens > maxTokens {
|
||||
break
|
||||
}
|
||||
selected = append(selected, item)
|
||||
totalTokens += estimatedTokens
|
||||
documentUsage[item.DocumentID]++
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func (s *retrieve) BuildContext(ctx context.Context, results []RetrieveResult, maxTokens int) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedResults := s.SelectContextResults(results, maxTokens)
|
||||
context := ""
|
||||
for _, r := range normalizedResults {
|
||||
chunkText := buildContextChunkText(r)
|
||||
context += chunkText
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
func normalizeContextResults(results []RetrieveResult) []RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
merged := mergeAdjacentResults(results)
|
||||
return dedupeSectionResults(merged)
|
||||
}
|
||||
|
||||
func dedupeSectionResults(results []RetrieveResult) []RetrieveResult {
|
||||
seen := make(map[string]struct{})
|
||||
deduped := make([]RetrieveResult, 0, len(results))
|
||||
for _, item := range results {
|
||||
key := buildSectionKey(item)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
deduped = append(deduped, item)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func mergeAdjacentResults(results []RetrieveResult) []RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
merged := make([]RetrieveResult, 0, len(results))
|
||||
for _, item := range results {
|
||||
if len(merged) == 0 {
|
||||
merged = append(merged, item)
|
||||
continue
|
||||
}
|
||||
|
||||
last := &merged[len(merged)-1]
|
||||
if canMergeContextResult(*last, item) {
|
||||
last.Content = strings.TrimSpace(last.Content + "\n" + item.Content)
|
||||
if item.Score > last.Score {
|
||||
last.Score = item.Score
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged = append(merged, item)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func canMergeContextResult(left, right RetrieveResult) bool {
|
||||
if left.FaqID > 0 || right.FaqID > 0 {
|
||||
return false
|
||||
}
|
||||
if left.DocumentID != right.DocumentID {
|
||||
return false
|
||||
}
|
||||
if left.SectionPath == "" || right.SectionPath == "" {
|
||||
return false
|
||||
}
|
||||
if left.SectionPath != right.SectionPath {
|
||||
return false
|
||||
}
|
||||
return right.ChunkNo == left.ChunkNo+1
|
||||
}
|
||||
|
||||
func buildSectionKey(item RetrieveResult) string {
|
||||
if item.FaqID > 0 {
|
||||
return fmt.Sprintf("faq:%d", item.FaqID)
|
||||
}
|
||||
sectionPath := strings.TrimSpace(item.SectionPath)
|
||||
if sectionPath != "" {
|
||||
return fmt.Sprintf("%d|%s", item.DocumentID, sectionPath)
|
||||
}
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title != "" {
|
||||
return fmt.Sprintf("%d|%s", item.DocumentID, title)
|
||||
}
|
||||
return fmt.Sprintf("%d|chunk:%d", item.DocumentID, item.ChunkNo)
|
||||
}
|
||||
|
||||
func buildContextChunkText(item RetrieveResult) string {
|
||||
if item.FaqID > 0 {
|
||||
title := strings.TrimSpace(item.FaqQuestion)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("FAQ#%d", item.FaqID)
|
||||
}
|
||||
return fmt.Sprintf("【FAQ:%s】\n%s\n\n", title, item.Content)
|
||||
}
|
||||
title := strings.TrimSpace(item.DocumentTitle)
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("文档#%d", item.DocumentID)
|
||||
}
|
||||
if item.SectionPath != "" {
|
||||
return fmt.Sprintf("【文档:%s|章节:%s】\n%s\n\n", title, item.SectionPath, item.Content)
|
||||
}
|
||||
if item.Title != "" {
|
||||
return fmt.Sprintf("【文档:%s|标题:%s】\n%s\n\n", title, item.Title, item.Content)
|
||||
}
|
||||
return fmt.Sprintf("【文档:%s】\n%s\n\n", title, item.Content)
|
||||
}
|
||||
|
||||
func (s *retrieve) GetKnowledgeBaseStats(ctx context.Context, knowledgeBaseID int64) (*KnowledgeBaseStats, error) {
|
||||
knowledgeBase := repositories.KnowledgeBaseRepository.Get(sqls.DB(), knowledgeBaseID)
|
||||
if knowledgeBase == nil {
|
||||
return nil, fmt.Errorf("knowledge base not found")
|
||||
}
|
||||
|
||||
documentCount := repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
|
||||
chunkCount := repositories.KnowledgeChunkRepository.CountByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
|
||||
|
||||
publishedCount := repositories.KnowledgeDocumentRepository.Count(sqls.DB(), sqls.NewCnd().
|
||||
Eq("knowledge_base_id", knowledgeBaseID).
|
||||
Eq("status", enums.StatusOk))
|
||||
|
||||
return &KnowledgeBaseStats{
|
||||
KnowledgeBaseID: knowledgeBaseID,
|
||||
DocumentCount: documentCount,
|
||||
PublishedCount: publishedCount,
|
||||
ChunkCount: chunkCount,
|
||||
VectorCount: int(chunkCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeKnowledgeBaseIDs(ids []int64) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
normalized := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
normalized = append(normalized, id)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func resolveKnowledgeBaseSearchOptions(req RetrieveRequest, knowledgeBase *models.KnowledgeBase) (int, float32) {
|
||||
topK := req.TopK
|
||||
if topK <= 0 && knowledgeBase != nil && knowledgeBase.DefaultTopK > 0 {
|
||||
topK = knowledgeBase.DefaultTopK
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 8
|
||||
}
|
||||
|
||||
scoreThreshold := float32(req.ScoreThreshold)
|
||||
if scoreThreshold <= 0 && knowledgeBase != nil && knowledgeBase.DefaultScoreThreshold > 0 {
|
||||
scoreThreshold = float32(knowledgeBase.DefaultScoreThreshold)
|
||||
}
|
||||
if scoreThreshold <= 0 {
|
||||
scoreThreshold = 0.3
|
||||
}
|
||||
return topK, scoreThreshold
|
||||
}
|
||||
|
||||
func (s *retrieve) loadRetrievableKnowledgeBases(ids []int64) []models.KnowledgeBase {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := repositories.KnowledgeBaseRepository.Find(sqls.DB(), sqls.NewCnd().In("id", ids))
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := make(map[int64]models.KnowledgeBase, len(items))
|
||||
for _, item := range items {
|
||||
if item.Status == enums.StatusOk {
|
||||
allowed[item.ID] = item
|
||||
}
|
||||
}
|
||||
filtered := make([]models.KnowledgeBase, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if item, ok := allowed[id]; ok {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type KnowledgeBaseStats struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DocumentCount int64 `json:"documentCount"`
|
||||
PublishedCount int64 `json:"publishedCount"`
|
||||
ChunkCount int64 `json:"chunkCount"`
|
||||
VectorCount int `json:"vectorCount"`
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var RetrieveLog = &retrieveLog{}
|
||||
|
||||
type retrieveLog struct {
|
||||
}
|
||||
|
||||
type CreateRetrieveLogRequest struct {
|
||||
KnowledgeBaseID int64
|
||||
Channel string
|
||||
Scene string
|
||||
SessionID string
|
||||
ConversationID int64
|
||||
Question string
|
||||
RewriteQuestion string
|
||||
Answer string
|
||||
AnswerStatus int
|
||||
ChunkProvider string
|
||||
ChunkTargetTokens int
|
||||
ChunkMaxTokens int
|
||||
ChunkOverlapTokens int
|
||||
RerankEnabled bool
|
||||
RerankLimit int
|
||||
Hits []response.KnowledgeSearchResult
|
||||
UsedHits []response.KnowledgeSearchResult
|
||||
Citations []response.KnowledgeCitation
|
||||
LatencyMs int64
|
||||
RetrieveMs int64
|
||||
GenerateMs int64
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
ModelName string
|
||||
}
|
||||
|
||||
type retrieveTraceData struct {
|
||||
Retrieve retrieveTraceRetrieve `json:"retrieve"`
|
||||
ChunkConfig retrieveTraceChunkConfig `json:"chunkConfig"`
|
||||
Context retrieveTraceContext `json:"context"`
|
||||
Citations []retrieveTraceCitation `json:"citations"`
|
||||
}
|
||||
|
||||
type retrieveTraceRetrieve struct {
|
||||
Provider string `json:"provider"`
|
||||
RerankEnabled bool `json:"rerankEnabled"`
|
||||
RerankLimit int `json:"rerankLimit"`
|
||||
RawHitCount int `json:"rawHitCount"`
|
||||
ContextHitCount int `json:"contextHitCount"`
|
||||
CitationCount int `json:"citationCount"`
|
||||
}
|
||||
|
||||
type retrieveTraceChunkConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
TargetTokens int `json:"targetTokens"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
OverlapTokens int `json:"overlapTokens"`
|
||||
}
|
||||
|
||||
type retrieveTraceContext struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
DocumentIDs []int64 `json:"documentIds"`
|
||||
SectionPaths []string `json:"sectionPaths"`
|
||||
UsedChunkKeys []string `json:"usedChunkKeys"`
|
||||
}
|
||||
|
||||
type retrieveTraceCitation struct {
|
||||
DocumentID int64 `json:"documentId"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
}
|
||||
|
||||
func (s *retrieveLog) FindHitsByRetrieveLogID(retrieveLogID int64) []models.KnowledgeRetrieveHit {
|
||||
if retrieveLogID <= 0 {
|
||||
return nil
|
||||
}
|
||||
var list []models.KnowledgeRetrieveHit
|
||||
sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("rank_no asc, id asc").Find(&list)
|
||||
return list
|
||||
}
|
||||
|
||||
func (s *retrieveLog) CreateRetrieveLog(req *CreateRetrieveLogRequest, _ *dto.AuthPrincipal) (*models.KnowledgeRetrieveLog, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("retrieve log request is nil")
|
||||
}
|
||||
now := time.Now()
|
||||
topScore := 0.0
|
||||
if len(req.Hits) > 0 {
|
||||
topScore = req.Hits[0].Score
|
||||
}
|
||||
traceData := buildRetrieveTraceData(req)
|
||||
|
||||
log := &models.KnowledgeRetrieveLog{
|
||||
KnowledgeBaseID: req.KnowledgeBaseID,
|
||||
Channel: req.Channel,
|
||||
Scene: req.Scene,
|
||||
SessionID: req.SessionID,
|
||||
ConversationID: req.ConversationID,
|
||||
RequestID: uuid.New().String(),
|
||||
Question: req.Question,
|
||||
RewriteQuestion: req.RewriteQuestion,
|
||||
Answer: req.Answer,
|
||||
AnswerStatus: req.AnswerStatus,
|
||||
HitCount: len(req.Hits),
|
||||
TopScore: topScore,
|
||||
ChunkProvider: req.ChunkProvider,
|
||||
ChunkTargetTokens: req.ChunkTargetTokens,
|
||||
ChunkMaxTokens: req.ChunkMaxTokens,
|
||||
ChunkOverlapTokens: req.ChunkOverlapTokens,
|
||||
RerankEnabled: req.RerankEnabled,
|
||||
RerankLimit: req.RerankLimit,
|
||||
CitationCount: len(req.Citations),
|
||||
UsedChunkCount: len(req.UsedHits),
|
||||
LatencyMs: req.LatencyMs,
|
||||
RetrieveMs: req.RetrieveMs,
|
||||
GenerateMs: req.GenerateMs,
|
||||
PromptTokens: req.PromptTokens,
|
||||
CompletionTokens: req.CompletionTokens,
|
||||
ModelName: req.ModelName,
|
||||
TraceData: traceData,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
usedHitKeys := make(map[string]struct{}, len(req.UsedHits))
|
||||
for _, item := range req.UsedHits {
|
||||
usedHitKeys[buildKnowledgeSearchResultKey(item)] = struct{}{}
|
||||
}
|
||||
citationKeys := make(map[string]struct{}, len(req.Citations))
|
||||
for _, item := range req.Citations {
|
||||
citationKeys[buildKnowledgeCitationKey(item)] = struct{}{}
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Create(log).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i, hit := range req.Hits {
|
||||
hitKey := buildKnowledgeSearchResultKey(hit)
|
||||
hitRecord := &models.KnowledgeRetrieveHit{
|
||||
RetrieveLogID: log.ID,
|
||||
KnowledgeBaseID: hit.KnowledgeBaseID,
|
||||
ChunkID: hit.ChunkID,
|
||||
DocumentID: hit.DocumentID,
|
||||
DocumentTitle: hit.DocumentTitle,
|
||||
FaqID: hit.FaqID,
|
||||
FaqQuestion: hit.FaqQuestion,
|
||||
ChunkNo: hit.ChunkNo,
|
||||
Title: hit.Title,
|
||||
SectionPath: hit.SectionPath,
|
||||
ChunkType: "",
|
||||
Provider: req.ChunkProvider,
|
||||
RankNo: i + 1,
|
||||
Score: hit.Score,
|
||||
RerankScore: hit.RerankScore,
|
||||
UsedInAnswer: hasHitKey(usedHitKeys, hitKey),
|
||||
IsCitation: hasHitKey(citationKeys, hitKey),
|
||||
Snippet: hit.Content,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := ctx.Tx.Create(hitRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func buildRetrieveTraceData(req *CreateRetrieveLogRequest) string {
|
||||
trace := retrieveTraceData{
|
||||
Retrieve: retrieveTraceRetrieve{
|
||||
Provider: req.ChunkProvider,
|
||||
RerankEnabled: req.RerankEnabled,
|
||||
RerankLimit: req.RerankLimit,
|
||||
RawHitCount: len(req.Hits),
|
||||
ContextHitCount: len(req.UsedHits),
|
||||
CitationCount: len(req.Citations),
|
||||
},
|
||||
ChunkConfig: retrieveTraceChunkConfig{
|
||||
Provider: req.ChunkProvider,
|
||||
TargetTokens: req.ChunkTargetTokens,
|
||||
MaxTokens: req.ChunkMaxTokens,
|
||||
OverlapTokens: req.ChunkOverlapTokens,
|
||||
},
|
||||
Context: retrieveTraceContext{
|
||||
KnowledgeBaseIDs: distinctKnowledgeBaseIDs(req.UsedHits),
|
||||
DocumentIDs: distinctDocumentIDs(req.UsedHits),
|
||||
SectionPaths: distinctSectionPaths(req.UsedHits),
|
||||
UsedChunkKeys: buildUsedChunkKeys(req.UsedHits),
|
||||
},
|
||||
Citations: buildTraceCitations(req.Citations),
|
||||
}
|
||||
data, err := json.Marshal(trace)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildTraceCitations(citations []response.KnowledgeCitation) []retrieveTraceCitation {
|
||||
items := make([]retrieveTraceCitation, 0, len(citations))
|
||||
for _, item := range citations {
|
||||
items = append(items, retrieveTraceCitation{
|
||||
DocumentID: item.DocumentID,
|
||||
ChunkNo: item.ChunkNo,
|
||||
SectionPath: item.SectionPath,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildUsedChunkKeys(hits []response.KnowledgeSearchResult) []string {
|
||||
keys := make([]string, 0, len(hits))
|
||||
for _, item := range hits {
|
||||
keys = append(keys, buildKnowledgeSearchResultKey(item))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func distinctKnowledgeBaseIDs(hits []response.KnowledgeSearchResult) []int64 {
|
||||
ids := make([]int64, 0)
|
||||
seen := make(map[int64]struct{})
|
||||
for _, item := range hits {
|
||||
if item.KnowledgeBaseID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.KnowledgeBaseID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.KnowledgeBaseID] = struct{}{}
|
||||
ids = append(ids, item.KnowledgeBaseID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func distinctDocumentIDs(hits []response.KnowledgeSearchResult) []int64 {
|
||||
seen := make(map[int64]struct{})
|
||||
items := make([]int64, 0)
|
||||
for _, item := range hits {
|
||||
if item.DocumentID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.DocumentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.DocumentID] = struct{}{}
|
||||
items = append(items, item.DocumentID)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func distinctSectionPaths(hits []response.KnowledgeSearchResult) []string {
|
||||
seen := make(map[string]struct{})
|
||||
items := make([]string, 0)
|
||||
for _, item := range hits {
|
||||
sectionPath := item.SectionPath
|
||||
if sectionPath == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[sectionPath]; ok {
|
||||
continue
|
||||
}
|
||||
seen[sectionPath] = struct{}{}
|
||||
items = append(items, sectionPath)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildKnowledgeSearchResultKey(item response.KnowledgeSearchResult) string {
|
||||
if item.FaqID > 0 {
|
||||
return fmt.Sprintf("faq:%d|%d", item.FaqID, item.ChunkNo)
|
||||
}
|
||||
return fmt.Sprintf("%d|%s|%d", item.DocumentID, item.SectionPath, item.ChunkNo)
|
||||
}
|
||||
|
||||
func buildKnowledgeCitationKey(item response.KnowledgeCitation) string {
|
||||
if item.FaqID > 0 {
|
||||
return fmt.Sprintf("faq:%d|%d", item.FaqID, item.ChunkNo)
|
||||
}
|
||||
return fmt.Sprintf("%d|%s|%d", item.DocumentID, item.SectionPath, item.ChunkNo)
|
||||
}
|
||||
|
||||
func hasHitKey(items map[string]struct{}, key string) bool {
|
||||
_, ok := items[key]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveKnowledgeBaseSearchOptionsUsesKnowledgeBaseDefaults(t *testing.T) {
|
||||
topK, scoreThreshold := resolveKnowledgeBaseSearchOptions(RetrieveRequest{}, &models.KnowledgeBase{
|
||||
DefaultTopK: 6,
|
||||
DefaultScoreThreshold: 0.42,
|
||||
})
|
||||
|
||||
if topK != 6 {
|
||||
t.Fatalf("expected topK 6, got %d", topK)
|
||||
}
|
||||
if scoreThreshold != float32(0.42) {
|
||||
t.Fatalf("expected score threshold 0.42, got %v", scoreThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKnowledgeBaseSearchOptionsRequestOverridesKnowledgeBaseDefaults(t *testing.T) {
|
||||
topK, scoreThreshold := resolveKnowledgeBaseSearchOptions(RetrieveRequest{
|
||||
TopK: 9,
|
||||
ScoreThreshold: 0.55,
|
||||
}, &models.KnowledgeBase{
|
||||
DefaultTopK: 6,
|
||||
DefaultScoreThreshold: 0.42,
|
||||
})
|
||||
|
||||
if topK != 9 {
|
||||
t.Fatalf("expected request topK 9, got %d", topK)
|
||||
}
|
||||
if scoreThreshold != float32(0.55) {
|
||||
t.Fatalf("expected request score threshold 0.55, got %v", scoreThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKnowledgeBaseSearchOptionsUsesSystemDefaults(t *testing.T) {
|
||||
topK, scoreThreshold := resolveKnowledgeBaseSearchOptions(RetrieveRequest{}, nil)
|
||||
|
||||
if topK != 8 {
|
||||
t.Fatalf("expected fallback topK 8, got %d", topK)
|
||||
}
|
||||
if scoreThreshold != float32(0.3) {
|
||||
t.Fatalf("expected fallback score threshold 0.3, got %v", scoreThreshold)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package rag
|
||||
|
||||
type RetrieveRequest struct {
|
||||
KnowledgeBaseIDs []int64
|
||||
Query string
|
||||
TopK int
|
||||
ScoreThreshold float64
|
||||
}
|
||||
|
||||
type RetrieveResult struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ChunkID int64 `json:"chunkId"`
|
||||
DocumentID int64 `json:"documentId"`
|
||||
DocumentTitle string `json:"documentTitle"`
|
||||
FaqID int64 `json:"faqId"`
|
||||
FaqQuestion string `json:"faqQuestion"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
Title string `json:"title"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
Content string `json:"content"`
|
||||
Score float32 `json:"score"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
}
|
||||
|
||||
type RerankRequest struct {
|
||||
Model string `json:"model"`
|
||||
Query string `json:"query"`
|
||||
Documents []string `json:"documents"`
|
||||
TopN int `json:"top_n"`
|
||||
}
|
||||
|
||||
type RerankResponse struct {
|
||||
Results []struct {
|
||||
Document string `json:"document"`
|
||||
Index int `json:"index"`
|
||||
RelevanceScore float64 `json:"relevance_score"`
|
||||
} `json:"results"`
|
||||
Meta struct {
|
||||
APIVersion struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"api_version"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
type RerankResult struct {
|
||||
Index int `json:"index"`
|
||||
RelevanceScore float64 `json:"relevanceScore"`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
htmlparser "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var plainTextMarkdown = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithHardWraps(),
|
||||
html.WithXHTML(),
|
||||
),
|
||||
)
|
||||
|
||||
func ExtractPlainText(content string, contentType enums.KnowledgeDocumentContentType) string {
|
||||
switch contentType {
|
||||
case enums.KnowledgeDocumentContentTypeMarkdown:
|
||||
return ExtractPlainTextFromMarkdown(content)
|
||||
case enums.KnowledgeDocumentContentTypeHTML:
|
||||
return ExtractPlainTextFromHTML(content)
|
||||
default:
|
||||
return normalizeWhitespace(content)
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractPlainTextFromMarkdown(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := plainTextMarkdown.Convert([]byte(content), &buf); err != nil {
|
||||
return normalizeWhitespace(content)
|
||||
}
|
||||
return ExtractPlainTextFromHTML(buf.String())
|
||||
}
|
||||
|
||||
func ExtractPlainTextFromHTML(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
parent := &htmlparser.Node{
|
||||
Type: htmlparser.ElementNode,
|
||||
Data: "div",
|
||||
}
|
||||
nodes, err := htmlparser.ParseFragment(strings.NewReader(content), parent)
|
||||
if err == nil {
|
||||
for _, node := range nodes {
|
||||
writeHTMLNodeText(&builder, node)
|
||||
}
|
||||
return normalizeWhitespace(builder.String())
|
||||
}
|
||||
|
||||
// 兜底:部分输入在 ParseFragment 下会失败(例如不符合 fragment 规则或上下文不匹配)。
|
||||
// 这里用完整 HTML 解析保证可用性。
|
||||
doc, err := htmlparser.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return normalizeWhitespace(content)
|
||||
}
|
||||
writeHTMLNodeText(&builder, doc)
|
||||
return normalizeWhitespace(builder.String())
|
||||
}
|
||||
|
||||
func writeHTMLNodeText(builder *strings.Builder, node *htmlparser.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case htmlparser.TextNode:
|
||||
builder.WriteString(node.Data)
|
||||
case htmlparser.ElementNode:
|
||||
if shouldSeparateHTMLText(node.Data) {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
writeHTMLNodeText(builder, child)
|
||||
}
|
||||
|
||||
if node.Type == htmlparser.ElementNode && shouldSeparateHTMLText(node.Data) {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSeparateHTMLText(tag string) bool {
|
||||
switch tag {
|
||||
case "p", "div", "br", "li", "ul", "ol", "blockquote", "pre", "table", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWhitespace(content string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(content)), " ")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"cs-agent/internal/ai/rag/vectordb"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPlainTextFromHTMLSeparatesBlockContent(t *testing.T) {
|
||||
got := ExtractPlainTextFromHTML("<div>Hello</div><div>World</div><p>Again</p>")
|
||||
want := "Hello World Again"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPlainTextMarkdownUsesGoldmark(t *testing.T) {
|
||||
got := ExtractPlainText("# Title\n\n- one\n- two", enums.KnowledgeDocumentContentTypeMarkdown)
|
||||
want := "Title one two"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPayloadFromMapSupportsTypedConversion(t *testing.T) {
|
||||
got := vectordb.ChunkPayloadFromMap(map[string]any{
|
||||
"knowledge_base_id": "1",
|
||||
"document_id": "123",
|
||||
"document_title": "Doc",
|
||||
"chunk_no": "2",
|
||||
"chunk_type": "text",
|
||||
"section_path": "A > B",
|
||||
"title": "hello",
|
||||
"content": "world",
|
||||
"provider": "structured",
|
||||
})
|
||||
if got.KnowledgeBaseID != 1 || got.DocumentID != 123 || got.ChunkNo != 2 {
|
||||
t.Fatalf("unexpected numeric conversion result: %+v", got)
|
||||
}
|
||||
if got.DocumentTitle != "Doc" || got.SectionPath != "A > B" || got.Provider != "structured" {
|
||||
t.Fatalf("unexpected string conversion result: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"github.com/mlogclub/simple/common/structs"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type ChunkPayload struct {
|
||||
KnowledgeBaseID int64 `json:"knowledge_base_id"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
DocumentTitle string `json:"document_title"`
|
||||
FaqID int64 `json:"faq_id"`
|
||||
FaqQuestion string `json:"faq_question"`
|
||||
ChunkNo int `json:"chunk_no"`
|
||||
ChunkType string `json:"chunk_type"`
|
||||
SectionPath string `json:"section_path"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
func (p ChunkPayload) ToMap() map[string]any {
|
||||
return structs.StructToMap(p)
|
||||
}
|
||||
|
||||
func ChunkPayloadFromMap(data map[string]any) ChunkPayload {
|
||||
if data == nil {
|
||||
return ChunkPayload{}
|
||||
}
|
||||
return ChunkPayload{
|
||||
KnowledgeBaseID: cast.ToInt64(data["knowledge_base_id"]),
|
||||
DocumentID: cast.ToInt64(data["document_id"]),
|
||||
DocumentTitle: cast.ToString(data["document_title"]),
|
||||
FaqID: cast.ToInt64(data["faq_id"]),
|
||||
FaqQuestion: cast.ToString(data["faq_question"]),
|
||||
ChunkNo: cast.ToInt(data["chunk_no"]),
|
||||
ChunkType: cast.ToString(data["chunk_type"]),
|
||||
SectionPath: cast.ToString(data["section_path"]),
|
||||
Title: cast.ToString(data["title"]),
|
||||
Content: cast.ToString(data["content"]),
|
||||
Provider: cast.ToString(data["provider"]),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
var defaultProvider Provider
|
||||
|
||||
func Init(cfg *config.VectorDBConfig) error {
|
||||
if cfg == nil || cfg.Type == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
switch enums.VectorDBType(cfg.Type) {
|
||||
case enums.VectorDBTypeQdrant:
|
||||
defaultProvider, err = NewQdrantProvider(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unsupported vectordb type: %s", cfg.Type)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func GetProvider() Provider {
|
||||
return defaultProvider
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
if defaultProvider != nil {
|
||||
return defaultProvider.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateCollection(ctx context.Context, name string, dimension int) error {
|
||||
if defaultProvider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.CreateCollection(ctx, name, dimension)
|
||||
}
|
||||
|
||||
func DeleteCollection(ctx context.Context, name string) error {
|
||||
if defaultProvider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.DeleteCollection(ctx, name)
|
||||
}
|
||||
|
||||
func GetCollection(ctx context.Context, name string) (*CollectionInfo, error) {
|
||||
if defaultProvider == nil {
|
||||
return nil, fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.GetCollection(ctx, name)
|
||||
}
|
||||
|
||||
func ListCollections(ctx context.Context) ([]string, error) {
|
||||
if defaultProvider == nil {
|
||||
return nil, fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.ListCollections(ctx)
|
||||
}
|
||||
|
||||
func UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error {
|
||||
if defaultProvider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.UpsertVectors(ctx, collectionName, vectors)
|
||||
}
|
||||
|
||||
func DeleteVectors(ctx context.Context, collectionName string, ids []string) error {
|
||||
if defaultProvider == nil {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.DeleteVectors(ctx, collectionName, ids)
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error) {
|
||||
if defaultProvider == nil {
|
||||
return nil, fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
return defaultProvider.Search(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/qdrant/go-client/qdrant"
|
||||
|
||||
"cs-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
type Vector struct {
|
||||
ID string `json:"id"`
|
||||
Vector []float32 `json:"vector"`
|
||||
Payload ChunkPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
CollectionName string `json:"collectionName"`
|
||||
Vector []float32 `json:"vector"`
|
||||
TopK int `json:"topK"`
|
||||
ScoreThreshold float32 `json:"scoreThreshold"`
|
||||
Filter *SearchFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds,omitempty"`
|
||||
DocumentIDs []int64 `json:"documentIds,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Score float32 `json:"score"`
|
||||
Payload ChunkPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type CollectionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Dimension int `json:"dimension"`
|
||||
PointCount int `json:"pointCount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
CreateCollection(ctx context.Context, name string, dimension int) error
|
||||
DeleteCollection(ctx context.Context, name string) error
|
||||
GetCollection(ctx context.Context, name string) (*CollectionInfo, error)
|
||||
ListCollections(ctx context.Context) ([]string, error)
|
||||
|
||||
UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error
|
||||
DeleteVectors(ctx context.Context, collectionName string, ids []string) error
|
||||
|
||||
Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type QdrantProvider struct {
|
||||
client *qdrant.Client
|
||||
}
|
||||
|
||||
func NewQdrantProvider(cfg *config.VectorDBConfig) (*QdrantProvider, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("vectordb config is nil")
|
||||
}
|
||||
|
||||
host := cfg.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
port := cfg.GrpcPort
|
||||
if port <= 0 {
|
||||
port = 6334
|
||||
}
|
||||
|
||||
client, err := qdrant.NewClient(&qdrant.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
APIKey: cfg.APIKey,
|
||||
UseTLS: cfg.UseTLS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create qdrant client: %w", err)
|
||||
}
|
||||
|
||||
return &QdrantProvider{client: client}, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) Close() error {
|
||||
if p.client != nil {
|
||||
return p.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) CreateCollection(ctx context.Context, name string, dimension int) error {
|
||||
err := p.client.CreateCollection(ctx, &qdrant.CreateCollection{
|
||||
CollectionName: name,
|
||||
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
|
||||
Size: uint64(dimension),
|
||||
Distance: qdrant.Distance_Cosine,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create collection %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) DeleteCollection(ctx context.Context, name string) error {
|
||||
err := p.client.DeleteCollection(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) GetCollection(ctx context.Context, name string) (*CollectionInfo, error) {
|
||||
info, err := p.client.GetCollectionInfo(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection %s: %w", name, err)
|
||||
}
|
||||
|
||||
status := info.GetStatus().String()
|
||||
pointCount := int(info.GetPointsCount())
|
||||
|
||||
dimension := 0
|
||||
if info.Config != nil && info.Config.Params != nil {
|
||||
vectorsConfig := info.Config.Params.VectorsConfig
|
||||
if vectorsConfig != nil {
|
||||
params := vectorsConfig.GetParams()
|
||||
if params != nil {
|
||||
dimension = int(params.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &CollectionInfo{
|
||||
Name: name,
|
||||
Dimension: dimension,
|
||||
PointCount: pointCount,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) ListCollections(ctx context.Context) ([]string, error) {
|
||||
collections, err := p.client.ListCollections(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list collections: %w", err)
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error {
|
||||
if len(vectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
points := make([]*qdrant.PointStruct, 0, len(vectors))
|
||||
for _, v := range vectors {
|
||||
points = append(points, &qdrant.PointStruct{
|
||||
Id: qdrant.NewID(v.ID),
|
||||
Vectors: qdrant.NewVectors(v.Vector...),
|
||||
Payload: qdrant.NewValueMap(v.Payload.ToMap()),
|
||||
})
|
||||
}
|
||||
|
||||
_, err := p.client.Upsert(ctx, &qdrant.UpsertPoints{
|
||||
CollectionName: collectionName,
|
||||
Points: points,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert vectors to collection %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) DeleteVectors(ctx context.Context, collectionName string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pointIDs := make([]*qdrant.PointId, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
pointIDs = append(pointIDs, qdrant.NewID(id))
|
||||
}
|
||||
|
||||
_, err := p.client.Delete(ctx, &qdrant.DeletePoints{
|
||||
CollectionName: collectionName,
|
||||
Points: &qdrant.PointsSelector{
|
||||
PointsSelectorOneOf: &qdrant.PointsSelector_Points{
|
||||
Points: &qdrant.PointsIdsList{
|
||||
Ids: pointIDs,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete vectors from collection %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error) {
|
||||
filter := p.buildFilter(req.Filter)
|
||||
|
||||
results, err := p.client.Query(ctx, &qdrant.QueryPoints{
|
||||
CollectionName: req.CollectionName,
|
||||
Query: qdrant.NewQuery(req.Vector...),
|
||||
Limit: qdrant.PtrOf(uint64(req.TopK)),
|
||||
ScoreThreshold: &req.ScoreThreshold,
|
||||
Filter: filter,
|
||||
WithPayload: qdrant.NewWithPayload(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search collection %s: %w", req.CollectionName, err)
|
||||
}
|
||||
|
||||
searchResults := make([]SearchResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
payload := make(map[string]any)
|
||||
if r.Payload != nil {
|
||||
for k, v := range r.Payload {
|
||||
payload[k] = p.extractPayloadValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
id := ""
|
||||
if r.Id != nil {
|
||||
id = r.Id.GetUuid()
|
||||
}
|
||||
|
||||
searchResults = append(searchResults, SearchResult{
|
||||
ID: id,
|
||||
Score: r.Score,
|
||||
Payload: ChunkPayloadFromMap(payload),
|
||||
})
|
||||
}
|
||||
|
||||
return searchResults, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) buildFilter(filter *SearchFilter) *qdrant.Filter {
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
must := make([]*qdrant.Condition, 0, 2)
|
||||
if len(filter.KnowledgeBaseIDs) > 0 {
|
||||
must = append(must, qdrant.NewMatchInts("knowledge_base_id", filter.KnowledgeBaseIDs...))
|
||||
}
|
||||
if len(filter.DocumentIDs) > 0 {
|
||||
must = append(must, qdrant.NewMatchInts("document_id", filter.DocumentIDs...))
|
||||
}
|
||||
if len(must) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &qdrant.Filter{Must: must}
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) extractPayloadValue(v *qdrant.Value) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch val := v.Kind.(type) {
|
||||
case *qdrant.Value_StringValue:
|
||||
return val.StringValue
|
||||
case *qdrant.Value_IntegerValue:
|
||||
return val.IntegerValue
|
||||
case *qdrant.Value_DoubleValue:
|
||||
return val.DoubleValue
|
||||
case *qdrant.Value_BoolValue:
|
||||
return val.BoolValue
|
||||
case *qdrant.Value_ListValue:
|
||||
list := make([]interface{}, 0, len(val.ListValue.Values))
|
||||
for _, item := range val.ListValue.Values {
|
||||
list = append(list, p.extractPayloadValue(item))
|
||||
}
|
||||
return list
|
||||
case *qdrant.Value_StructValue:
|
||||
m := make(map[string]interface{})
|
||||
for k, v := range val.StructValue.Fields {
|
||||
m[k] = p.extractPayloadValue(v)
|
||||
}
|
||||
return m
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/rag"
|
||||
"cs-agent/internal/ai/runtime/internal/impl/adapter"
|
||||
"cs-agent/internal/ai/runtime/internal/impl/callbacks"
|
||||
"cs-agent/internal/ai/runtime/internal/impl/factory"
|
||||
"cs-agent/internal/ai/runtime/internal/impl/retrievers"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
agentFactory *factory.AgentFactory
|
||||
runnerFactory *factory.RunnerFactory
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
agentFactory: factory.NewAgentFactory(),
|
||||
runnerFactory: factory.NewRunnerFactory(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
summary := &Summary{
|
||||
RunID: uuid.NewString(),
|
||||
Status: "started",
|
||||
ToolCodes: make([]string, 0),
|
||||
InvokedToolCodes: make([]string, 0),
|
||||
}
|
||||
collector := callbacks.NewRuntimeTraceCollector()
|
||||
collector.Data.RunID = summary.RunID
|
||||
if req.AIAgent == nil || req.Conversation == nil || req.UserMessage == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "invalid runtime request"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
if req.AIConfig == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "ai config is nil"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
|
||||
history := adapter.BuildHistoryMessages(req.Conversation.ID, req.UserMessage.ID, 12)
|
||||
summary.HistoryMessageCount = len(history.Messages)
|
||||
collector.Data.Input.HistoryMessageCount = len(history.Messages)
|
||||
collector.Data.Input.KnowledgeBaseIDs = utils.SplitInt64s(req.AIAgent.KnowledgeIDs)
|
||||
collector.Data.Input.CurrentUserMessagePreview = preview(req.UserMessage.Content, 120)
|
||||
|
||||
toolDefs, err := factory.NewToolFactory().BuildMCPTools(req.AIAgent)
|
||||
if err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = err.Error()
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = err.Error()
|
||||
collector.Data.Error.Stage = "prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, err
|
||||
}
|
||||
toolDefsByModelName := make(map[string]string, len(toolDefs))
|
||||
for _, item := range toolDefs {
|
||||
summary.ToolCodes = append(summary.ToolCodes, item.ToolCode)
|
||||
toolDefsByModelName[item.ModelName] = item.ToolCode
|
||||
}
|
||||
for modelName, toolCode := range req.ExtraToolCodes {
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if toolCode == "" || modelName == "" {
|
||||
continue
|
||||
}
|
||||
summary.ToolCodes = appendIfMissing(summary.ToolCodes, toolCode)
|
||||
toolDefsByModelName[modelName] = toolCode
|
||||
}
|
||||
collector.Data.Input.ToolCodes = append(collector.Data.Input.ToolCodes, summary.ToolCodes...)
|
||||
|
||||
collector.Data.Model.Provider = string(req.AIConfig.Provider)
|
||||
collector.Data.Model.Name = req.AIConfig.ModelName
|
||||
|
||||
agent, err := s.agentFactory.BuildCustomerServiceAgent(ctx, req.AIAgent, req.AIConfig, toolDefs, req.ExtraTools, req.ExtraToolCodes, collector)
|
||||
if err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = err.Error()
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = err.Error()
|
||||
collector.Data.Error.Stage = "prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, err
|
||||
}
|
||||
|
||||
checkPointID := strings.TrimSpace(req.CheckPointID)
|
||||
if checkPointID == "" {
|
||||
checkPointID = "eino_cp_" + summary.RunID
|
||||
}
|
||||
summary.CheckPointID = checkPointID
|
||||
runner := s.runnerFactory.Build(ctx, agent, false, true)
|
||||
if runner == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "failed to build runner"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
messages := make([]*schema.Message, 0, len(history.Messages)+3)
|
||||
messages = append(messages, history.Messages...)
|
||||
|
||||
retriever := retrievers.NewKnowledgeRetriever(req.AIAgent)
|
||||
if results, _, retrieveErr := retriever.Retrieve(ctx, strings.TrimSpace(req.UserMessage.Content)); retrieveErr == nil {
|
||||
summary.RetrieverCount = len(results)
|
||||
collector.Data.Retriever.Count = len(results)
|
||||
for _, item := range results {
|
||||
collector.Data.Retriever.Items = append(collector.Data.Retriever.Items, callbacks.RetrieverTraceItem{
|
||||
Query: preview(req.UserMessage.Content, 120),
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
Score: float64(item.Score),
|
||||
})
|
||||
}
|
||||
if knowledgeContext := buildKnowledgeContext(results); knowledgeContext != "" {
|
||||
messages = append(messages, schema.SystemMessage(knowledgeContext))
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, schema.UserMessage(strings.TrimSpace(req.UserMessage.Content)))
|
||||
collector.Data.Interrupt.CheckPointID = checkPointID
|
||||
consumeAgentEvents(runner.Run(ctx, messages, buildRunOptions(checkPointID)...), summary, collector, toolDefsByModelName)
|
||||
summary.ModelName = req.AIConfig.ModelName
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Output.ReplyText = summary.ReplyText
|
||||
collector.Data.Output.FinishReason = summary.Status
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||
summary := &Summary{
|
||||
RunID: uuid.NewString(),
|
||||
Status: "started",
|
||||
CheckPointID: strings.TrimSpace(req.CheckPointID),
|
||||
ToolCodes: make([]string, 0),
|
||||
InvokedToolCodes: make([]string, 0),
|
||||
Interrupts: make([]InterruptContextSummary, 0),
|
||||
}
|
||||
collector := callbacks.NewRuntimeTraceCollector()
|
||||
collector.Data.RunID = summary.RunID
|
||||
collector.Data.Interrupt.CheckPointID = summary.CheckPointID
|
||||
if req.AIAgent == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "ai agent is nil"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
if req.AIConfig == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "ai config is nil"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
if summary.CheckPointID == "" {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "checkpoint id is required"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
toolDefs, err := factory.NewToolFactory().BuildMCPTools(req.AIAgent)
|
||||
if err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = err.Error()
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = err.Error()
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, err
|
||||
}
|
||||
toolDefsByModelName := make(map[string]string, len(toolDefs))
|
||||
for _, item := range toolDefs {
|
||||
summary.ToolCodes = append(summary.ToolCodes, item.ToolCode)
|
||||
toolDefsByModelName[item.ModelName] = item.ToolCode
|
||||
}
|
||||
for modelName, toolCode := range req.ExtraToolCodes {
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if toolCode == "" || modelName == "" {
|
||||
continue
|
||||
}
|
||||
summary.ToolCodes = appendIfMissing(summary.ToolCodes, toolCode)
|
||||
toolDefsByModelName[modelName] = toolCode
|
||||
}
|
||||
collector.Data.Input.ToolCodes = append(collector.Data.Input.ToolCodes, summary.ToolCodes...)
|
||||
collector.Data.Model.Provider = string(req.AIConfig.Provider)
|
||||
collector.Data.Model.Name = req.AIConfig.ModelName
|
||||
agent, err := s.agentFactory.BuildCustomerServiceAgent(ctx, req.AIAgent, req.AIConfig, toolDefs, req.ExtraTools, req.ExtraToolCodes, collector)
|
||||
if err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = err.Error()
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = err.Error()
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, err
|
||||
}
|
||||
runner := s.runnerFactory.Build(ctx, agent, false, true)
|
||||
if runner == nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = "failed to build runner"
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = summary.ErrorMessage
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, fmt.Errorf("%s", summary.ErrorMessage)
|
||||
}
|
||||
var iter *adk.AsyncIterator[*adk.AgentEvent]
|
||||
if len(req.ResumeData) > 0 {
|
||||
iter, err = runner.ResumeWithParams(ctx, summary.CheckPointID, &adk.ResumeParams{Targets: req.ResumeData})
|
||||
} else {
|
||||
iter, err = runner.Resume(ctx, summary.CheckPointID)
|
||||
}
|
||||
if err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = err.Error()
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Error.Message = err.Error()
|
||||
collector.Data.Error.Stage = "resume_prepare"
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, err
|
||||
}
|
||||
consumeAgentEvents(iter, summary, collector, toolDefsByModelName)
|
||||
summary.ModelName = req.AIConfig.ModelName
|
||||
collector.Data.Status = summary.Status
|
||||
collector.Data.Output.ReplyText = summary.ReplyText
|
||||
collector.Data.Output.FinishReason = summary.Status
|
||||
summary.TraceData = collector.Marshal()
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func buildRunOptions(checkPointID string) []adk.AgentRunOption {
|
||||
if strings.TrimSpace(checkPointID) == "" {
|
||||
return nil
|
||||
}
|
||||
return []adk.AgentRunOption{adk.WithCheckPointID(checkPointID)}
|
||||
}
|
||||
|
||||
func consumeAgentEvents(iter *adk.AsyncIterator[*adk.AgentEvent], summary *Summary, collector *callbacks.RuntimeTraceCollector, toolDefsByModelName map[string]string) {
|
||||
if iter == nil || summary == nil || collector == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
event, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
if event.Err != nil {
|
||||
summary.Status = "error"
|
||||
summary.ErrorMessage = event.Err.Error()
|
||||
collector.Data.Error.Message = event.Err.Error()
|
||||
collector.Data.Error.Stage = "model"
|
||||
continue
|
||||
}
|
||||
if event.Action != nil && event.Action.Interrupted != nil {
|
||||
summary.Status = "interrupted"
|
||||
summary.Interrupted = true
|
||||
summary.Interrupts = summarizeInterrupts(event.Action.Interrupted.InterruptContexts)
|
||||
collector.Data.Interrupt.Items = convertInterruptTraceItems(summary.Interrupts)
|
||||
continue
|
||||
}
|
||||
if event.Output == nil || event.Output.MessageOutput == nil {
|
||||
continue
|
||||
}
|
||||
message, getErr := event.Output.MessageOutput.GetMessage()
|
||||
if getErr != nil || message == nil {
|
||||
continue
|
||||
}
|
||||
switch event.Output.MessageOutput.Role {
|
||||
case schema.Assistant:
|
||||
summary.ReplyText = strings.TrimSpace(message.Content)
|
||||
case schema.Tool:
|
||||
summary.ToolCallCount++
|
||||
if toolDefsByModelName != nil {
|
||||
toolCode := strings.TrimSpace(toolDefsByModelName[message.ToolName])
|
||||
if toolCode != "" {
|
||||
summary.InvokedToolCodes = appendIfMissing(summary.InvokedToolCodes, toolCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if summary.Status == "started" {
|
||||
if strings.TrimSpace(summary.ReplyText) == "" {
|
||||
summary.Status = "fallback"
|
||||
} else {
|
||||
summary.Status = "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func convertInterruptTraceItems(items []InterruptContextSummary) []callbacks.InterruptTraceContext {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]callbacks.InterruptTraceContext, 0, len(items))
|
||||
for _, item := range items {
|
||||
ret = append(ret, callbacks.InterruptTraceContext{
|
||||
Type: item.Type,
|
||||
ID: item.ID,
|
||||
InfoPreview: item.InfoPreview,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func previewInterruptInfo(info any) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := info.(type) {
|
||||
case string:
|
||||
return preview(v, 200)
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return preview(string(data), 200)
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeInterrupts(items []*adk.InterruptCtx) []InterruptContextSummary {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]InterruptContextSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, InterruptContextSummary{
|
||||
Type: extractInterruptType(item.Info),
|
||||
ID: strings.TrimSpace(item.ID),
|
||||
InfoPreview: previewInterruptInfo(item.Info),
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func extractInterruptType(info any) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := info.(type) {
|
||||
case map[string]any:
|
||||
return strings.TrimSpace(getStringFromAnyMap(v, "type"))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getStringFromAnyMap(data map[string]any, key string) string {
|
||||
value, ok := data[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func appendIfMissing(items []string, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return items
|
||||
}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) == value {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return append(items, value)
|
||||
}
|
||||
|
||||
func preview(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func buildKnowledgeContext(items []rag.RetrieveResult) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("以下是可供参考的知识库内容,请优先基于这些内容回答;如果仍不确定,请明确说明并向用户澄清。\n\n")
|
||||
for i, item := range items {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
builder.WriteString("[知识片段")
|
||||
builder.WriteString(fmt.Sprintf("%d", i+1))
|
||||
builder.WriteString("]\n")
|
||||
if strings.TrimSpace(item.DocumentTitle) != "" {
|
||||
builder.WriteString("标题: ")
|
||||
builder.WriteString(strings.TrimSpace(item.DocumentTitle))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if strings.TrimSpace(item.Content) != "" {
|
||||
builder.WriteString("内容: ")
|
||||
builder.WriteString(strings.TrimSpace(item.Content))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Conversation *models.Conversation
|
||||
UserMessage *models.Message
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
CheckPointID string
|
||||
ExtraTools []einotool.BaseTool
|
||||
ExtraToolCodes map[string]string
|
||||
}
|
||||
|
||||
type ResumeRequest struct {
|
||||
Conversation *models.Conversation
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
CheckPointID string
|
||||
ResumeData map[string]any
|
||||
ExtraTools []einotool.BaseTool
|
||||
ExtraToolCodes map[string]string
|
||||
}
|
||||
|
||||
type InterruptContextSummary struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InfoPreview string `json:"infoPreview,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
RunID string
|
||||
Status string
|
||||
ReplyText string
|
||||
ModelName string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
HistoryMessageCount int
|
||||
RetrieverCount int
|
||||
ToolCallCount int
|
||||
ToolCodes []string
|
||||
InvokedToolCodes []string
|
||||
CheckPointID string
|
||||
Interrupted bool
|
||||
Interrupts []InterruptContextSummary
|
||||
TraceData string
|
||||
ErrorMessage string
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package adapter
|
||||
|
||||
import "cs-agent/internal/models"
|
||||
|
||||
type AIConfigSnapshot struct {
|
||||
ID int64
|
||||
Provider string
|
||||
ModelName string
|
||||
BaseURL string
|
||||
MaxOutputTokens int
|
||||
TimeoutMS int
|
||||
}
|
||||
|
||||
func BuildAIConfigSnapshot(item *models.AIConfig) *AIConfigSnapshot {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &AIConfigSnapshot{
|
||||
ID: item.ID,
|
||||
Provider: string(item.Provider),
|
||||
ModelName: item.ModelName,
|
||||
BaseURL: item.BaseURL,
|
||||
MaxOutputTokens: item.MaxOutputTokens,
|
||||
TimeoutMS: item.TimeoutMS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package adapter
|
||||
|
||||
import "cs-agent/internal/models"
|
||||
|
||||
type ConversationSnapshot struct {
|
||||
ID int64
|
||||
AIAgentID int64
|
||||
LastMessageID int64
|
||||
CurrentAssigneeID int64
|
||||
}
|
||||
|
||||
func BuildConversationSnapshot(item *models.Conversation) *ConversationSnapshot {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &ConversationSnapshot{
|
||||
ID: item.ID,
|
||||
AIAgentID: item.AIAgentID,
|
||||
LastMessageID: item.LastMessageID,
|
||||
CurrentAssigneeID: item.CurrentAssigneeID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/mcps"
|
||||
|
||||
einojsonschema "github.com/eino-contrib/jsonschema"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
var toolNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
|
||||
type MCPToolDefinition struct {
|
||||
ToolCode string
|
||||
ServerCode string
|
||||
ToolName string
|
||||
ModelName string
|
||||
Title string
|
||||
Description string
|
||||
FixedArgs map[string]string
|
||||
}
|
||||
|
||||
type MCPTool struct {
|
||||
definition MCPToolDefinition
|
||||
info *schema.ToolInfo
|
||||
}
|
||||
|
||||
func NewMCPTool(definition MCPToolDefinition, metadata *mcps.ToolInfo) *MCPTool {
|
||||
return &MCPTool{
|
||||
definition: definition,
|
||||
info: buildToolInfo(definition, metadata),
|
||||
}
|
||||
}
|
||||
|
||||
var _ einotool.InvokableTool = (*MCPTool)(nil)
|
||||
|
||||
func (t *MCPTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
if t == nil || t.info == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return t.info, nil
|
||||
}
|
||||
|
||||
func (t *MCPTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
if t == nil {
|
||||
return "", fmt.Errorf("mcp tool is nil")
|
||||
}
|
||||
arguments, err := parseArguments(argumentsInJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
arguments = mergeFixedArguments(arguments, t.definition.FixedArgs)
|
||||
result, err := mcps.Runtime.CallTool(ctx, t.definition.ServerCode, t.definition.ToolName, arguments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildToolResultSummary(result), nil
|
||||
}
|
||||
|
||||
func buildToolInfo(definition MCPToolDefinition, metadata *mcps.ToolInfo) *schema.ToolInfo {
|
||||
desc := strings.TrimSpace(definition.Description)
|
||||
if desc == "" && metadata != nil {
|
||||
desc = strings.TrimSpace(metadata.Description)
|
||||
}
|
||||
title := strings.TrimSpace(definition.Title)
|
||||
if title == "" && metadata != nil {
|
||||
title = strings.TrimSpace(metadata.Title)
|
||||
}
|
||||
if title != "" && desc != "" {
|
||||
desc = title + "\n\n" + desc
|
||||
} else if title != "" {
|
||||
desc = title
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "Call MCP tool " + strings.TrimSpace(definition.ToolCode)
|
||||
}
|
||||
info := &schema.ToolInfo{
|
||||
Name: BuildModelToolName(definition),
|
||||
Desc: desc,
|
||||
Extra: map[string]any{
|
||||
"toolCode": definition.ToolCode,
|
||||
"serverCode": definition.ServerCode,
|
||||
"toolName": definition.ToolName,
|
||||
},
|
||||
}
|
||||
if js := buildParamsSchema(metadata); js != nil {
|
||||
info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(js)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func buildParamsSchema(metadata *mcps.ToolInfo) *einojsonschema.Schema {
|
||||
if metadata == nil || metadata.InputSchema == nil {
|
||||
return genericObjectSchema()
|
||||
}
|
||||
raw, err := json.Marshal(metadata.InputSchema)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return genericObjectSchema()
|
||||
}
|
||||
js := &einojsonschema.Schema{}
|
||||
if err := json.Unmarshal(raw, js); err != nil {
|
||||
return genericObjectSchema()
|
||||
}
|
||||
return js
|
||||
}
|
||||
|
||||
func genericObjectSchema() *einojsonschema.Schema {
|
||||
return &einojsonschema.Schema{
|
||||
Version: einojsonschema.Version,
|
||||
Type: "object",
|
||||
AdditionalProperties: &einojsonschema.Schema{},
|
||||
}
|
||||
}
|
||||
|
||||
func parseArguments(argumentsInJSON string) (map[string]any, error) {
|
||||
argumentsInJSON = strings.TrimSpace(argumentsInJSON)
|
||||
if argumentsInJSON == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
args := make(map[string]any)
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool arguments: %w", err)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func mergeFixedArguments(arguments map[string]any, fixedArgs map[string]string) map[string]any {
|
||||
if len(arguments) == 0 && len(fixedArgs) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
ret := make(map[string]any, len(arguments)+len(fixedArgs))
|
||||
for key, value := range arguments {
|
||||
ret[key] = value
|
||||
}
|
||||
for key, value := range fixedArgs {
|
||||
ret[key] = strings.TrimSpace(value)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildToolResultSummary(result *mcps.ToolCallResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(result.Content)+2)
|
||||
if result.IsError {
|
||||
lines = append(lines, "tool returned an error")
|
||||
}
|
||||
if result.StructuredContent != nil {
|
||||
if data, err := json.Marshal(result.StructuredContent); err == nil {
|
||||
lines = append(lines, string(data))
|
||||
}
|
||||
}
|
||||
for _, item := range result.Content {
|
||||
switch item.Type {
|
||||
case "text":
|
||||
if text := strings.TrimSpace(item.Text); text != "" {
|
||||
lines = append(lines, text)
|
||||
}
|
||||
default:
|
||||
if item.Data == nil {
|
||||
continue
|
||||
}
|
||||
if data, err := json.Marshal(item.Data); err == nil {
|
||||
lines = append(lines, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func BuildModelToolName(definition MCPToolDefinition) string {
|
||||
if strings.TrimSpace(definition.ModelName) != "" {
|
||||
return strings.TrimSpace(definition.ModelName)
|
||||
}
|
||||
base := "mcp_" + strings.TrimSpace(definition.ServerCode) + "_" + strings.TrimSpace(definition.ToolName)
|
||||
base = toolNameSanitizer.ReplaceAllString(base, "_")
|
||||
base = strings.Trim(base, "_")
|
||||
if base == "" {
|
||||
base = "mcp_tool"
|
||||
}
|
||||
checksum := crc32.ChecksumIEEE([]byte(definition.ToolCode))
|
||||
return fmt.Sprintf("%s_%08x", base, checksum)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const defaultHistoryLimit = 12
|
||||
|
||||
type HistoryBuildResult struct {
|
||||
Messages []*schema.Message
|
||||
RawItems []models.Message
|
||||
}
|
||||
|
||||
func BuildHistoryMessages(conversationID int64, currentMessageID int64, limit int) HistoryBuildResult {
|
||||
if conversationID <= 0 {
|
||||
return HistoryBuildResult{}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultHistoryLimit
|
||||
}
|
||||
items := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd().
|
||||
Eq("conversation_id", conversationID).
|
||||
Desc("id").
|
||||
Limit(limit+1))
|
||||
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
ret := HistoryBuildResult{
|
||||
Messages: make([]*schema.Message, 0, len(items)),
|
||||
RawItems: make([]models.Message, 0, len(items)),
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == currentMessageID {
|
||||
continue
|
||||
}
|
||||
msg := BuildSchemaMessage(&item)
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
ret.RawItems = append(ret.RawItems, item)
|
||||
ret.Messages = append(ret.Messages, msg)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildSchemaMessage(item *models.Message) *schema.Message {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
switch item.SenderType {
|
||||
case enums.IMSenderTypeCustomer:
|
||||
return schema.UserMessage(content)
|
||||
case enums.IMSenderTypeAI, enums.IMSenderTypeAgent:
|
||||
return schema.AssistantMessage(content, nil)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
type CustomerServiceAgent struct {
|
||||
Inner adk.Agent
|
||||
}
|
||||
|
||||
var _ adk.ResumableAgent = (*CustomerServiceAgent)(nil)
|
||||
|
||||
func (a *CustomerServiceAgent) Name(ctx context.Context) string {
|
||||
if a == nil || a.Inner == nil {
|
||||
return "customer_service_agent"
|
||||
}
|
||||
return a.Inner.Name(ctx)
|
||||
}
|
||||
|
||||
func (a *CustomerServiceAgent) Description(ctx context.Context) string {
|
||||
if a == nil || a.Inner == nil {
|
||||
return "customer service chat agent"
|
||||
}
|
||||
return a.Inner.Description(ctx)
|
||||
}
|
||||
|
||||
func (a *CustomerServiceAgent) Run(ctx context.Context, input *adk.AgentInput, options ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
if a == nil || a.Inner == nil {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
gen.Send(&adk.AgentEvent{Err: context.Canceled})
|
||||
gen.Close()
|
||||
return iter
|
||||
}
|
||||
return a.Inner.Run(ctx, input, options...)
|
||||
}
|
||||
|
||||
func (a *CustomerServiceAgent) Resume(ctx context.Context, info *adk.ResumeInfo, options ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
if a == nil || a.Inner == nil {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("customer service agent is not initialized")})
|
||||
gen.Close()
|
||||
return iter
|
||||
}
|
||||
ra, ok := a.Inner.(adk.ResumableAgent)
|
||||
if !ok {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("inner agent %q does not implement resumable agent", a.Inner.Name(ctx))})
|
||||
gen.Close()
|
||||
return iter
|
||||
}
|
||||
return ra.Resume(ctx, info, options...)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package callbacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
type ToolMetadata struct {
|
||||
ToolCode string
|
||||
ServerCode string
|
||||
ToolName string
|
||||
}
|
||||
|
||||
type RuntimeTraceHandler struct {
|
||||
*adk.BaseChatModelAgentMiddleware
|
||||
collector *RuntimeTraceCollector
|
||||
toolMetadataBy map[string]ToolMetadata
|
||||
}
|
||||
|
||||
func NewRuntimeTraceHandler(collector *RuntimeTraceCollector, toolMetadataBy map[string]ToolMetadata) *RuntimeTraceHandler {
|
||||
return &RuntimeTraceHandler{
|
||||
BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{},
|
||||
collector: collector,
|
||||
toolMetadataBy: toolMetadataBy,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RuntimeTraceHandler) WrapInvokableToolCall(_ context.Context, endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) (adk.InvokableToolCallEndpoint, error) {
|
||||
return func(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
startedAt := time.Now()
|
||||
result, err := endpoint(ctx, argumentsInJSON, opts...)
|
||||
item := ToolTraceItem{
|
||||
ResultPreview: previewToolText(result, 300),
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
Status: "ok",
|
||||
}
|
||||
if tCtx != nil {
|
||||
item.ToolName = strings.TrimSpace(tCtx.Name)
|
||||
if metadata, ok := h.toolMetadataBy[item.ToolName]; ok {
|
||||
item.ToolCode = metadata.ToolCode
|
||||
item.ServerCode = metadata.ServerCode
|
||||
item.ToolName = metadata.ToolName
|
||||
}
|
||||
}
|
||||
if arguments := parseToolArguments(argumentsInJSON); len(arguments) > 0 {
|
||||
item.Arguments = arguments
|
||||
}
|
||||
if err != nil {
|
||||
item.Status = "error"
|
||||
item.ErrorMessage = err.Error()
|
||||
}
|
||||
h.collector.AddToolItem(item)
|
||||
return result, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseToolArguments(argumentsInJSON string) map[string]any {
|
||||
argumentsInJSON = strings.TrimSpace(argumentsInJSON)
|
||||
if argumentsInJSON == "" {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]any)
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &ret); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func previewToolText(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package callbacks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type RuntimeTraceCollector struct {
|
||||
mu sync.Mutex
|
||||
Data RuntimeTraceData
|
||||
}
|
||||
|
||||
func NewRuntimeTraceCollector() *RuntimeTraceCollector {
|
||||
ret := &RuntimeTraceCollector{}
|
||||
ret.Data.Version = "v1"
|
||||
ret.Data.Status = "started"
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *RuntimeTraceCollector) Marshal() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
buf, err := json.Marshal(c.Data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func (c *RuntimeTraceCollector) AddToolItem(item ToolTraceItem) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Data.Tools.Count++
|
||||
c.Data.Tools.Items = append(c.Data.Tools.Items, item)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package callbacks
|
||||
|
||||
type ToolTraceItem struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
ResultPreview string `json:"resultPreview,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
type RetrieverTraceItem struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
|
||||
DocumentID int64 `json:"documentId,omitempty"`
|
||||
DocumentTitle string `json:"documentTitle,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeTraceData struct {
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Interrupt struct {
|
||||
CheckPointID string `json:"checkPointId,omitempty"`
|
||||
Items []InterruptTraceContext `json:"items,omitempty"`
|
||||
} `json:"interrupt"`
|
||||
Model struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
} `json:"model"`
|
||||
Input struct {
|
||||
HistoryMessageCount int `json:"historyMessageCount,omitempty"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds,omitempty"`
|
||||
ToolCodes []string `json:"toolCodes,omitempty"`
|
||||
CurrentUserMessagePreview string `json:"currentUserMessagePreview,omitempty"`
|
||||
} `json:"input"`
|
||||
Retriever struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Items []RetrieverTraceItem `json:"items,omitempty"`
|
||||
} `json:"retriever"`
|
||||
Tools struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Items []ToolTraceItem `json:"items,omitempty"`
|
||||
} `json:"tools"`
|
||||
Output struct {
|
||||
ReplyText string `json:"replyText,omitempty"`
|
||||
FinishReason string `json:"finishReason,omitempty"`
|
||||
} `json:"output"`
|
||||
Error struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type InterruptTraceContext struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InfoPreview string `json:"infoPreview,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
einoadapter "cs-agent/internal/ai/runtime/internal/impl/adapter"
|
||||
einoagents "cs-agent/internal/ai/runtime/internal/impl/agents"
|
||||
einocallbacks "cs-agent/internal/ai/runtime/internal/impl/callbacks"
|
||||
"cs-agent/internal/models"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
einobasetool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
type AgentFactory struct {
|
||||
chatModelFactory *ChatModelFactory
|
||||
toolFactory *ToolFactory
|
||||
}
|
||||
|
||||
func NewAgentFactory() *AgentFactory {
|
||||
return &AgentFactory{
|
||||
chatModelFactory: NewChatModelFactory(),
|
||||
toolFactory: NewToolFactory(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, aiAgent *models.AIAgent, aiConfig *models.AIConfig,
|
||||
toolDefinitions []einoadapter.MCPToolDefinition, extraTools []einobasetool.BaseTool, extraToolCodes map[string]string,
|
||||
collector *einocallbacks.RuntimeTraceCollector) (*einoagents.CustomerServiceAgent, error) {
|
||||
if aiAgent == nil || aiConfig == nil {
|
||||
return nil, nil
|
||||
}
|
||||
chatModel, err := f.chatModelFactory.Build(ctx, aiConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseTools, err := f.toolFactory.BuildBaseToolsByDefinitions(ctx, toolDefinitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allTools := make([]einobasetool.BaseTool, 0, len(baseTools)+len(extraTools))
|
||||
allTools = append(allTools, extraTools...)
|
||||
allTools = append(allTools, baseTools...)
|
||||
handlers := make([]adk.ChatModelAgentMiddleware, 0, 1)
|
||||
if collector != nil {
|
||||
toolMetadataBy := make(map[string]einocallbacks.ToolMetadata, len(toolDefinitions))
|
||||
for _, item := range toolDefinitions {
|
||||
toolMetadataBy[item.ModelName] = einocallbacks.ToolMetadata{
|
||||
ToolCode: item.ToolCode,
|
||||
ServerCode: item.ServerCode,
|
||||
ToolName: item.ToolName,
|
||||
}
|
||||
}
|
||||
handlers = append(handlers, einocallbacks.NewRuntimeTraceHandler(collector, toolMetadataBy))
|
||||
}
|
||||
inner, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: strings.TrimSpace(aiAgent.Name),
|
||||
Description: strings.TrimSpace(aiAgent.Description),
|
||||
Instruction: buildAgentInstruction(aiAgent, extraToolCodes),
|
||||
Model: chatModel,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: allTools,
|
||||
},
|
||||
},
|
||||
Handlers: handlers,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &einoagents.CustomerServiceAgent{Inner: inner}, nil
|
||||
}
|
||||
|
||||
func buildAgentInstruction(aiAgent *models.AIAgent, extraToolCodes map[string]string) string {
|
||||
baseInstruction := ""
|
||||
if aiAgent != nil {
|
||||
baseInstruction = strings.TrimSpace(aiAgent.SystemPrompt)
|
||||
}
|
||||
appendixParts := make([]string, 0, 1)
|
||||
if hasToolCode(extraToolCodes, "builtin/create_ticket_with_confirmation") {
|
||||
appendixParts = append(appendixParts, strings.TrimSpace(`
|
||||
你可以在确认信息充分后调用 create_ticket_with_confirmation 工具来创建工单,但必须遵守以下规则:
|
||||
1. 只有在用户明确表达希望提交工单、投诉、报障、售后处理等诉求时,才考虑调用该工具。
|
||||
2. 调用前你必须已经整理出清晰的工单标题和问题描述;如果信息不足,先继续追问,不要过早调用。
|
||||
3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。
|
||||
4. 该工具会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。
|
||||
5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。
|
||||
`))
|
||||
}
|
||||
if len(appendixParts) == 0 {
|
||||
return baseInstruction
|
||||
}
|
||||
if baseInstruction == "" {
|
||||
return strings.Join(appendixParts, "\n\n")
|
||||
}
|
||||
return baseInstruction + "\n\n" + strings.Join(appendixParts, "\n\n")
|
||||
}
|
||||
|
||||
func hasToolCode(toolCodes map[string]string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, toolCode := range toolCodes {
|
||||
if strings.TrimSpace(toolCode) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
|
||||
openai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
)
|
||||
|
||||
type ChatModelFactory struct{}
|
||||
|
||||
func NewChatModelFactory() *ChatModelFactory {
|
||||
return &ChatModelFactory{}
|
||||
}
|
||||
|
||||
func (f *ChatModelFactory) Build(ctx context.Context, item *models.AIConfig) (model.ToolCallingChatModel, error) {
|
||||
if item == nil {
|
||||
return nil, nil
|
||||
}
|
||||
conf := &openai.ChatModelConfig{
|
||||
APIKey: strings.TrimSpace(item.APIKey),
|
||||
BaseURL: strings.TrimSpace(item.BaseURL),
|
||||
Model: strings.TrimSpace(item.ModelName),
|
||||
}
|
||||
if item.TimeoutMS > 0 {
|
||||
conf.Timeout = time.Duration(item.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
if item.MaxOutputTokens > 0 {
|
||||
maxCompletionTokens := item.MaxOutputTokens
|
||||
conf.MaxCompletionTokens = &maxCompletionTokens
|
||||
}
|
||||
if item.Provider == enums.AIProviderOpenAI && isAzureOpenAIBaseURL(item.BaseURL) {
|
||||
conf.ByAzure = true
|
||||
conf.APIVersion = "2024-06-01"
|
||||
}
|
||||
if extraFields := providerExtraFields(item); len(extraFields) > 0 {
|
||||
conf.ExtraFields = extraFields
|
||||
}
|
||||
return openai.NewChatModel(ctx, conf)
|
||||
}
|
||||
|
||||
func isAzureOpenAIBaseURL(baseURL string) bool {
|
||||
baseURL = strings.ToLower(strings.TrimSpace(baseURL))
|
||||
return strings.Contains(baseURL, ".openai.azure.com")
|
||||
}
|
||||
|
||||
func providerExtraFields(item *models.AIConfig) map[string]any {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(item.BaseURL))
|
||||
modelName := strings.ToLower(strings.TrimSpace(item.ModelName))
|
||||
if strings.Contains(baseURL, "dashscope.aliyuncs.com") && strings.HasPrefix(modelName, "qwen3") {
|
||||
return map[string]any{
|
||||
"enable_thinking": false,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
einostore "cs-agent/internal/ai/runtime/internal/impl/store"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
type RunnerFactory struct{}
|
||||
|
||||
func NewRunnerFactory() *RunnerFactory {
|
||||
return &RunnerFactory{}
|
||||
}
|
||||
|
||||
func (f *RunnerFactory) Build(ctx context.Context, agent adk.Agent, enableStreaming bool, enableCheckpoint bool) *adk.Runner {
|
||||
var checkpointStore adk.CheckPointStore
|
||||
if enableCheckpoint {
|
||||
checkpointStore = einostore.DefaultCheckPointStore
|
||||
}
|
||||
return adk.NewRunner(ctx, adk.RunnerConfig{
|
||||
Agent: agent,
|
||||
EnableStreaming: enableStreaming,
|
||||
CheckPointStore: checkpointStore,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/mcps"
|
||||
impladapter "cs-agent/internal/ai/runtime/internal/impl/adapter"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type ToolFactory struct{}
|
||||
|
||||
func NewToolFactory() *ToolFactory {
|
||||
return &ToolFactory{}
|
||||
}
|
||||
|
||||
func (f *ToolFactory) BuildMCPTools(aiAgent *models.AIAgent) ([]impladapter.MCPToolDefinition, error) {
|
||||
if aiAgent == nil || strings.TrimSpace(aiAgent.AllowedMCPTools) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []request.AIAgentMCPToolRequest
|
||||
if err := json.Unmarshal([]byte(aiAgent.AllowedMCPTools), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make([]impladapter.MCPToolDefinition, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
toolCode := strings.TrimSpace(item.ServerCode) + "/" + strings.TrimSpace(item.ToolName)
|
||||
definition := impladapter.MCPToolDefinition{
|
||||
ToolCode: toolCode,
|
||||
ServerCode: strings.TrimSpace(item.ServerCode),
|
||||
ToolName: strings.TrimSpace(item.ToolName),
|
||||
Title: strings.TrimSpace(item.Title),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
FixedArgs: cloneStringMap(item.Arguments),
|
||||
}
|
||||
definition.ModelName = impladapter.BuildModelToolName(definition)
|
||||
ret = append(ret, definition)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (f *ToolFactory) BuildBaseTools(ctx context.Context, aiAgent *models.AIAgent) ([]einotool.BaseTool, error) {
|
||||
definitions, err := f.BuildMCPTools(aiAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.BuildBaseToolsByDefinitions(ctx, definitions)
|
||||
}
|
||||
|
||||
func (f *ToolFactory) BuildBaseToolsByDefinitions(ctx context.Context, definitions []impladapter.MCPToolDefinition) ([]einotool.BaseTool, error) {
|
||||
if len(definitions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metadataByCode, err := f.loadToolMetadata(ctx, definitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make([]einotool.BaseTool, 0, len(definitions))
|
||||
for _, item := range definitions {
|
||||
ret = append(ret, impladapter.NewMCPTool(item, metadataByCode[item.ToolCode]))
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (f *ToolFactory) loadToolMetadata(ctx context.Context, definitions []impladapter.MCPToolDefinition) (map[string]*mcps.ToolInfo, error) {
|
||||
toolsByCode := make(map[string]*mcps.ToolInfo, len(definitions))
|
||||
serverCodes := make(map[string]struct{})
|
||||
for _, item := range definitions {
|
||||
serverCodes[item.ServerCode] = struct{}{}
|
||||
}
|
||||
for serverCode := range serverCodes {
|
||||
toolInfos, err := mcps.Runtime.ListTools(ctx, serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range toolInfos {
|
||||
toolInfo := toolInfos[i]
|
||||
toolCode := strings.TrimSpace(serverCode) + "/" + strings.TrimSpace(toolInfo.Name)
|
||||
toolInfoCopy := toolInfo
|
||||
toolsByCode[toolCode] = &toolInfoCopy
|
||||
}
|
||||
}
|
||||
return toolsByCode, nil
|
||||
}
|
||||
|
||||
func cloneStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
ret[key] = value
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package retrievers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cs-agent/internal/ai/rag"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type KnowledgeRetriever struct {
|
||||
AIAgent *models.AIAgent
|
||||
}
|
||||
|
||||
func NewKnowledgeRetriever(aiAgent *models.AIAgent) *KnowledgeRetriever {
|
||||
return &KnowledgeRetriever{AIAgent: aiAgent}
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) KnowledgeBaseIDs() []int64 {
|
||||
if r == nil || r.AIAgent == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.SplitInt64s(r.AIAgent.KnowledgeIDs)
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) Retrieve(ctx context.Context, query string) ([]rag.RetrieveResult, *rag.RetrieveTrace, error) {
|
||||
ids := r.KnowledgeBaseIDs()
|
||||
return rag.Retrieve.RetrieveWithTrace(ctx, rag.RetrieveRequest{
|
||||
Query: query,
|
||||
KnowledgeBaseIDs: ids,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var DefaultCheckPointStore adk.CheckPointStore = NewDBCheckPointStore()
|
||||
|
||||
type DBCheckPointStore struct{}
|
||||
|
||||
func NewDBCheckPointStore() *DBCheckPointStore {
|
||||
return &DBCheckPointStore{}
|
||||
}
|
||||
|
||||
func (s *DBCheckPointStore) Get(_ context.Context, checkPointID string) ([]byte, bool, error) {
|
||||
item := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), checkPointID)
|
||||
if item == nil || item.CheckPointData == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
return decodeCheckPointData(item.CheckPointData)
|
||||
}
|
||||
|
||||
func (s *DBCheckPointStore) Set(_ context.Context, checkPointID string, checkPoint []byte) error {
|
||||
item := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), checkPointID)
|
||||
if item == nil {
|
||||
item = buildEmptyInterrupt(checkPointID)
|
||||
}
|
||||
item.CheckPointData = encodeCheckPointData(checkPoint)
|
||||
if item.ConversationID == 0 && item.AIAgentID == 0 && item.SourceMessageID == 0 && item.Status == "" {
|
||||
return repositories.ConversationInterruptRepository.Create(sqls.DB(), item)
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.UpsertByCheckPointID(sqls.DB(), item)
|
||||
}
|
||||
|
||||
func encodeCheckPointData(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func decodeCheckPointData(value string) ([]byte, bool, error) {
|
||||
if value == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func buildEmptyInterrupt(checkPointID string) *models.ConversationInterrupt {
|
||||
now := time.Now()
|
||||
return &models.ConversationInterrupt{
|
||||
CheckPointID: checkPointID,
|
||||
Status: "checkpointed",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
tools []Tool
|
||||
}
|
||||
|
||||
func NewRegistry(tools ...Tool) *Registry {
|
||||
return &Registry{
|
||||
tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
|
||||
ret := &ToolSet{
|
||||
Tools: make([]einotool.BaseTool, 0, len(r.tools)),
|
||||
ToolCodes: make(map[string]string),
|
||||
}
|
||||
for _, toolDef := range r.tools {
|
||||
if toolDef == nil || !toolDef.Enabled(ctx) {
|
||||
continue
|
||||
}
|
||||
tool, err := toolDef.Build(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(toolDef.Name())
|
||||
toolCode := strings.TrimSpace(toolDef.Code())
|
||||
if toolName == "" || toolCode == "" {
|
||||
continue
|
||||
}
|
||||
ret.Tools = append(ret.Tools, tool)
|
||||
ret.ToolCodes[toolName] = toolCode
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Conversation *models.Conversation
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
UserMessage *models.Message
|
||||
}
|
||||
|
||||
type ToolSet struct {
|
||||
Tools []einotool.BaseTool
|
||||
ToolCodes map[string]string
|
||||
}
|
||||
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Code() string
|
||||
Enabled(ctx Context) bool
|
||||
Build(ctx Context) (einotool.BaseTool, error)
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
svc "cs-agent/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AIReplyService = newAIReplyService()
|
||||
|
||||
func init() {
|
||||
svc.TriggerAIReplyAsyncHook = AIReplyService.TriggerReplyAsync
|
||||
}
|
||||
|
||||
func newAIReplyService() *aiReplyService {
|
||||
return &aiReplyService{}
|
||||
}
|
||||
|
||||
type aiReplyService struct{}
|
||||
|
||||
type aiReplyTraceData struct {
|
||||
Status string `json:"status"`
|
||||
RuntimeLatencyMs int64 `json:"runtimeLatencyMs,omitempty"`
|
||||
RecheckMs int64 `json:"recheckMs,omitempty"`
|
||||
CommitMs int64 `json:"commitMs,omitempty"`
|
||||
FinalAction string `json:"finalAction,omitempty"`
|
||||
ReplySent bool `json:"replySent,omitempty"`
|
||||
ReplyMessageID int64 `json:"replyMessageId,omitempty"`
|
||||
Runtime json.RawMessage `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultAIReplyAsyncTimeoutSeconds = 180
|
||||
maxAIReplyAsyncTimeoutSeconds = 600
|
||||
)
|
||||
|
||||
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
|
||||
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
||||
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
|
||||
}
|
||||
if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds {
|
||||
return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second
|
||||
}
|
||||
return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, message models.Message) {
|
||||
go func() {
|
||||
aiAgent := svc.AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
startedAt := time.Now()
|
||||
timeout := s.resolveReplyTimeout(*aiAgent)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil {
|
||||
slog.Error("failed to trigger ai reply",
|
||||
"message_id", message.ID,
|
||||
"timeout_ms", timeout.Milliseconds(),
|
||||
"elapsed_ms", time.Since(startedAt).Milliseconds(),
|
||||
"error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
|
||||
startedAt := time.Now()
|
||||
trace := &aiReplyTraceData{Status: "started"}
|
||||
var summary *Summary
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if message.SenderType != enums.IMSenderTypeCustomer {
|
||||
return nil
|
||||
}
|
||||
if conversation.HandoffAt != nil || conversation.CurrentAssigneeID > 0 {
|
||||
return nil
|
||||
}
|
||||
if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly {
|
||||
return nil
|
||||
}
|
||||
if strs.IsBlank(message.Content) {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
s.writeRunLog(startedAt, message, conversation, aiAgent, message.Content, retErr, trace, summary)
|
||||
}()
|
||||
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
||||
return s.resumePendingInterrupt(ctx, conversation, message, aiAgent, pendingInterrupt, trace, &summary)
|
||||
}
|
||||
if s.shouldHandoffByQuestion(message.Content, aiAgent) {
|
||||
return s.handoffConversation(conversation, aiAgent, "用户主动要求人工")
|
||||
}
|
||||
if aiAgent.ServiceMode != enums.IMConversationServiceModeAIOnly &&
|
||||
aiAgent.MaxAIReplyRounds > 0 &&
|
||||
conversation.AIReplyRounds >= aiAgent.MaxAIReplyRounds {
|
||||
return s.handoffConversation(conversation, aiAgent, "达到AI最大回复轮次")
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return fmt.Errorf("ai config is nil")
|
||||
}
|
||||
|
||||
runtimeStartedAt := time.Now()
|
||||
var err error
|
||||
summary, err = Service.Run(ctx, Request{
|
||||
Conversation: &conversation,
|
||||
UserMessage: &message,
|
||||
AIAgent: &aiAgent,
|
||||
AIConfig: aiConfig,
|
||||
})
|
||||
trace.RuntimeLatencyMs = time.Since(runtimeStartedAt).Milliseconds()
|
||||
if err != nil {
|
||||
trace.Status = "runtime_error"
|
||||
trace.FinalAction = "error"
|
||||
if summary != nil {
|
||||
trace.Runtime = json.RawMessage(summary.TraceData)
|
||||
}
|
||||
return err
|
||||
}
|
||||
trace.Status = "runtime_prepared"
|
||||
trace.FinalAction = toRunLogFinalAction(summary)
|
||||
if summary != nil && strings.TrimSpace(summary.TraceData) != "" {
|
||||
trace.Runtime = json.RawMessage(summary.TraceData)
|
||||
}
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.handleInterruptedSummary(conversation, message, aiAgent, summary, trace)
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_reply")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
trace.ReplySent = replyMessage != nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
|
||||
pendingInterrupt *models.ConversationInterrupt, trace *aiReplyTraceData, summaryRef **Summary) error {
|
||||
if pendingInterrupt == nil {
|
||||
return nil
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return fmt.Errorf("ai config is nil")
|
||||
}
|
||||
runtimeStartedAt := time.Now()
|
||||
summary, err := Service.Resume(ctx, ResumeRequest{
|
||||
Conversation: &conversation,
|
||||
AIAgent: &aiAgent,
|
||||
AIConfig: aiConfig,
|
||||
CheckPointID: strings.TrimSpace(pendingInterrupt.CheckPointID),
|
||||
ResumeData: map[string]any{
|
||||
strings.TrimSpace(pendingInterrupt.InterruptID): strings.TrimSpace(message.Content),
|
||||
},
|
||||
})
|
||||
trace.RuntimeLatencyMs = time.Since(runtimeStartedAt).Milliseconds()
|
||||
*summaryRef = summary
|
||||
if err != nil {
|
||||
if isCheckpointMissingError(err) {
|
||||
summary = &Summary{
|
||||
Status: "expired",
|
||||
ReplyText: "本次确认已失效,请重新发起。",
|
||||
}
|
||||
*summaryRef = summary
|
||||
trace.Status = "interrupt_expired"
|
||||
trace.FinalAction = "expired"
|
||||
replyMessage, expireErr := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_interrupt_expired")
|
||||
if expireErr != nil {
|
||||
return expireErr
|
||||
}
|
||||
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
lastResumeMessageID := int64(0)
|
||||
if replyMessage != nil {
|
||||
lastResumeMessageID = replyMessage.ID
|
||||
}
|
||||
if expireMarkErr := svc.ConversationInterruptService.MarkExpired(pendingInterrupt.ID, lastResumeMessageID); expireMarkErr != nil {
|
||||
return expireMarkErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
trace.Status = "runtime_error"
|
||||
trace.FinalAction = "error"
|
||||
if summary != nil {
|
||||
trace.Runtime = json.RawMessage(summary.TraceData)
|
||||
}
|
||||
return err
|
||||
}
|
||||
trace.Status = "runtime_prepared"
|
||||
trace.FinalAction = toRunLogFinalAction(summary)
|
||||
if summary != nil && strings.TrimSpace(summary.TraceData) != "" {
|
||||
trace.Runtime = json.RawMessage(summary.TraceData)
|
||||
}
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.handleInterruptedResume(conversation, message, aiAgent, pendingInterrupt, summary, trace)
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_resume")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
replyMessageID := int64(0)
|
||||
if replyMessage != nil {
|
||||
replyMessageID = replyMessage.ID
|
||||
}
|
||||
if isCancellationReply(summary.ReplyText) {
|
||||
return svc.ConversationInterruptService.MarkCancelled(pendingInterrupt.ID, replyMessageID)
|
||||
}
|
||||
return svc.ConversationInterruptService.MarkResolved(pendingInterrupt.ID, replyMessageID)
|
||||
}
|
||||
return svc.ConversationInterruptService.MarkResolved(pendingInterrupt.ID, 0)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) handleInterruptedSummary(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
|
||||
summary *Summary, trace *aiReplyTraceData) error {
|
||||
pending := buildConversationInterrupt(conversation, message, aiAgent, summary)
|
||||
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
|
||||
return err
|
||||
}
|
||||
pending = svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
|
||||
replyText := resolveInterruptPrompt(summary)
|
||||
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, replyText, trace, "ai_interrupt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if replyMessage != nil && pending != nil {
|
||||
return svc.ConversationInterruptService.MarkPendingAgain(pending.ID, pending.InterruptID, replyText, replyMessage.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiReplyService) handleInterruptedResume(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
|
||||
pendingInterrupt *models.ConversationInterrupt, summary *Summary, trace *aiReplyTraceData) error {
|
||||
if pendingInterrupt == nil {
|
||||
return nil
|
||||
}
|
||||
replyText := resolveInterruptPrompt(summary)
|
||||
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, replyText, trace, "ai_interrupt_resume")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if replyMessage != nil {
|
||||
return svc.ConversationInterruptService.MarkPendingAgain(pendingInterrupt.ID, firstInterruptID(summary), replyText, replyMessage.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiReplyService) sendAIReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
|
||||
replyText string, trace *aiReplyTraceData, clientPrefix string) (*models.Message, error) {
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
if replyText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
commitStartedAt := time.Now()
|
||||
replyMessage, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID,
|
||||
fmt.Sprintf("%s_%d", strings.TrimSpace(clientPrefix), message.ID), enums.IMMessageTypeText, replyText, "", s.buildAIPrincipal(aiAgent))
|
||||
if trace != nil {
|
||||
trace.CommitMs = time.Since(commitStartedAt).Milliseconds()
|
||||
trace.ReplySent = err == nil && replyMessage != nil
|
||||
if replyMessage != nil {
|
||||
trace.ReplyMessageID = replyMessage.ID
|
||||
}
|
||||
}
|
||||
return replyMessage, err
|
||||
}
|
||||
|
||||
func (s *aiReplyService) shouldHandoffByQuestion(question string, aiAgent models.AIAgent) bool {
|
||||
if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(question)), " ", "")
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{"转人工", "人工客服"}
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(normalized, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *aiReplyService) writeRunLog(startedAt time.Time, message models.Message, conversation models.Conversation, aiAgent models.AIAgent,
|
||||
question string, runErr error, trace *aiReplyTraceData, summary *Summary) {
|
||||
errorMessage := ""
|
||||
if runErr != nil {
|
||||
errorMessage = runErr.Error()
|
||||
} else if summary != nil && strings.TrimSpace(summary.ErrorMessage) != "" {
|
||||
errorMessage = strings.TrimSpace(summary.ErrorMessage)
|
||||
}
|
||||
traceData := buildAIReplyTraceData(trace)
|
||||
plannedAction, plannedToolCode, planReason := buildRunLogPlan(summary)
|
||||
logItem := &models.AgentRunLog{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
AIAgentID: aiAgent.ID,
|
||||
AIConfigID: aiAgent.AIConfigID,
|
||||
UserMessage: strings.TrimSpace(question),
|
||||
PlannedAction: plannedAction,
|
||||
PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(summary)),
|
||||
PlannedToolCode: plannedToolCode,
|
||||
PlanReason: planReason,
|
||||
FinalAction: toRunLogFinalAction(summary),
|
||||
ReplyText: buildRunLogReplyText(summary),
|
||||
ErrorMessage: errorMessage,
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
TraceData: traceData,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := svc.AgentRunLogService.Create(logItem); err != nil {
|
||||
slog.Warn("create agent run log failed",
|
||||
"message_id", message.ID,
|
||||
"conversation_id", logItem.ConversationID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAIReplyTraceData(trace *aiReplyTraceData) string {
|
||||
if trace == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(trace)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildRunLogPlan(summary *Summary) (plannedAction, plannedToolCode, planReason string) {
|
||||
if summary == nil {
|
||||
return "", "", ""
|
||||
}
|
||||
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" {
|
||||
reason := strings.TrimSpace(summary.PlanReason)
|
||||
if reason == "" {
|
||||
reason = "skill_selected"
|
||||
}
|
||||
return "skill", "", reason
|
||||
}
|
||||
if strings.TrimSpace(summary.Status) == "expired" {
|
||||
return "interrupt", "", "pending interrupt checkpoint expired"
|
||||
}
|
||||
if summary.Interrupted {
|
||||
return "tool", firstInvokedToolCode(summary), "agent interrupted and is waiting for user confirmation"
|
||||
}
|
||||
if len(summary.InvokedToolCodes) > 0 {
|
||||
return "tool", strings.TrimSpace(summary.InvokedToolCodes[0]), "agent invoked MCP tool"
|
||||
}
|
||||
if strings.TrimSpace(summary.ReplyText) != "" {
|
||||
return "reply", "", "agent replied directly"
|
||||
}
|
||||
if strings.TrimSpace(summary.ErrorMessage) != "" {
|
||||
return "error", "", "runtime execution failed"
|
||||
}
|
||||
return "fallback", "", "runtime produced empty reply"
|
||||
}
|
||||
|
||||
func toRunLogFinalAction(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
return "skill"
|
||||
}
|
||||
switch strings.TrimSpace(summary.Status) {
|
||||
case "completed":
|
||||
return "reply"
|
||||
case "fallback":
|
||||
return "fallback"
|
||||
case "error":
|
||||
return "error"
|
||||
case "interrupted":
|
||||
return "interrupted"
|
||||
case "expired":
|
||||
return "expired"
|
||||
default:
|
||||
return strings.TrimSpace(summary.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRunLogReplyText(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.ReplyText)
|
||||
}
|
||||
|
||||
func summaryPlannedSkillCode(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.PlannedSkillCode)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) incrementAIReplyRounds(conversationID int64, nextRounds int, aiAgentName string) error {
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), conversationID, map[string]any{
|
||||
"ai_reply_rounds": nextRounds,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": strings.TrimSpace(aiAgentName),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *Summary) *models.ConversationInterrupt {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
item := svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
|
||||
if item == nil {
|
||||
item = &models.ConversationInterrupt{
|
||||
CheckPointID: summary.CheckPointID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
item.ConversationID = conversation.ID
|
||||
item.AIAgentID = aiAgent.ID
|
||||
item.SourceMessageID = message.ID
|
||||
item.InterruptID = firstInterruptID(summary)
|
||||
item.InterruptType = firstInterruptType(summary)
|
||||
item.Status = "pending"
|
||||
item.PromptText = resolveInterruptPrompt(summary)
|
||||
item.UpdatedAt = now
|
||||
return item
|
||||
}
|
||||
|
||||
func resolveInterruptPrompt(summary *Summary) string {
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return "请继续补充信息后再试。"
|
||||
}
|
||||
if prompt := extractInterruptMessage(summary.Interrupts[0].InfoPreview); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
if prompt := strings.TrimSpace(summary.Interrupts[0].InfoPreview); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
return "请继续补充信息后再试。"
|
||||
}
|
||||
|
||||
func extractInterruptMessage(infoPreview string) string {
|
||||
infoPreview = strings.TrimSpace(infoPreview)
|
||||
if infoPreview == "" {
|
||||
return ""
|
||||
}
|
||||
payload := make(map[string]any)
|
||||
if err := json.Unmarshal([]byte(infoPreview), &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
if message, ok := payload["message"].(string); ok {
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstInterruptID(summary *Summary) string {
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.Interrupts[0].ID)
|
||||
}
|
||||
|
||||
func firstInterruptType(summary *Summary) string {
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.Interrupts[0].Type)
|
||||
}
|
||||
|
||||
func firstInvokedToolCode(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
if len(summary.InvokedToolCodes) > 0 {
|
||||
return strings.TrimSpace(summary.InvokedToolCodes[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCancellationReply(replyText string) bool {
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
return strings.Contains(replyText, "已取消本次工单创建")
|
||||
}
|
||||
|
||||
func isCheckpointMissingError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "failed to load from checkpoint") && strings.Contains(message, "not exist")
|
||||
}
|
||||
|
||||
func (s *aiReplyService) handoffConversation(conversation models.Conversation, aiAgent models.AIAgent, reason string) error {
|
||||
now := time.Now()
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{
|
||||
"handoff_at": now,
|
||||
"handoff_reason": strings.TrimSpace(reason),
|
||||
"status": enums.IMConversationStatusPending,
|
||||
"current_team_id": 0,
|
||||
"current_assignee_id": 0,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": aiAgent.Name,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID, fmt.Sprintf("ai_handoff_%d", conversation.LastMessageID), enums.IMMessageTypeText, "已为你转接人工客服,请稍候。", "", s.buildAIPrincipal(aiAgent)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := svc.ConversationDispatchService.DispatchConversation(conversation.ID); err != nil {
|
||||
slog.Warn("auto dispatch conversation after ai handoff failed",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiReplyService) buildAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
|
||||
username := "AI"
|
||||
if strings.TrimSpace(aiAgent.Name) != "" {
|
||||
username = aiAgent.Name
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/runtime/internal/engine"
|
||||
"cs-agent/internal/ai/runtime/registry"
|
||||
"cs-agent/internal/ai/runtime/tools"
|
||||
"cs-agent/internal/ai/skills"
|
||||
)
|
||||
|
||||
var Service = newService()
|
||||
|
||||
func newService() *service {
|
||||
return &service{
|
||||
runtime: engine.NewService(),
|
||||
registry: registry.NewRegistry(
|
||||
tools.NewCreateTicketConfirmTool(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type service struct {
|
||||
runtime *engine.Service
|
||||
registry *registry.Registry
|
||||
}
|
||||
|
||||
func (s *service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
skillSummary, skillErr := s.tryRunSkill(ctx, req)
|
||||
if skillSummary != nil && strings.TrimSpace(skillSummary.ReplyText) != "" {
|
||||
return skillSummary, nil
|
||||
}
|
||||
if err := s.prepareToolsForRun(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.runtime.Run(ctx, engine.Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
CheckPointID: req.CheckPointID,
|
||||
ExtraTools: req.ExtraTools,
|
||||
ExtraToolCodes: req.ExtraToolCodes,
|
||||
})
|
||||
if err != nil {
|
||||
ret := toSummary(summary)
|
||||
if ret != nil && skillErr != nil && strings.TrimSpace(ret.PlanReason) == "" {
|
||||
ret.PlanReason = "skill_failed_fallback_runtime"
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
ret := toSummary(summary)
|
||||
if ret != nil && skillErr != nil && strings.TrimSpace(ret.PlanReason) == "" {
|
||||
ret.PlanReason = "skill_failed_fallback_runtime"
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||
if err := s.prepareToolsForResume(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.runtime.Resume(ctx, engine.ResumeRequest{
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
CheckPointID: req.CheckPointID,
|
||||
ResumeData: req.ResumeData,
|
||||
ExtraTools: req.ExtraTools,
|
||||
ExtraToolCodes: req.ExtraToolCodes,
|
||||
})
|
||||
if err != nil {
|
||||
return toSummary(summary), err
|
||||
}
|
||||
return toSummary(summary), nil
|
||||
}
|
||||
|
||||
func (s *service) prepareToolsForRun(req *Request) error {
|
||||
if req == nil || len(req.ExtraTools) > 0 || len(req.ExtraToolCodes) > 0 || s.registry == nil {
|
||||
return nil
|
||||
}
|
||||
toolSet, err := s.registry.Resolve(registry.Context{
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
UserMessage: req.UserMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ExtraTools = toolSet.Tools
|
||||
req.ExtraToolCodes = toolSet.ToolCodes
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) prepareToolsForResume(req *ResumeRequest) error {
|
||||
if req == nil || len(req.ExtraTools) > 0 || len(req.ExtraToolCodes) > 0 || s.registry == nil {
|
||||
return nil
|
||||
}
|
||||
toolSet, err := s.registry.Resolve(registry.Context{
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ExtraTools = toolSet.Tools
|
||||
req.ExtraToolCodes = toolSet.ToolCodes
|
||||
return nil
|
||||
}
|
||||
|
||||
func toSummary(summary *engine.Summary) *Summary {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
ret := &Summary{
|
||||
RunID: summary.RunID,
|
||||
Status: summary.Status,
|
||||
ReplyText: summary.ReplyText,
|
||||
PlannedSkillCode: "",
|
||||
PlanReason: "",
|
||||
ModelName: summary.ModelName,
|
||||
PromptTokens: summary.PromptTokens,
|
||||
CompletionTokens: summary.CompletionTokens,
|
||||
HistoryMessageCount: summary.HistoryMessageCount,
|
||||
RetrieverCount: summary.RetrieverCount,
|
||||
ToolCallCount: summary.ToolCallCount,
|
||||
ToolCodes: append([]string(nil), summary.ToolCodes...),
|
||||
InvokedToolCodes: append([]string(nil), summary.InvokedToolCodes...),
|
||||
CheckPointID: summary.CheckPointID,
|
||||
Interrupted: summary.Interrupted,
|
||||
TraceData: summary.TraceData,
|
||||
ErrorMessage: summary.ErrorMessage,
|
||||
}
|
||||
if len(summary.Interrupts) > 0 {
|
||||
ret.Interrupts = make([]InterruptContextSummary, 0, len(summary.Interrupts))
|
||||
for _, item := range summary.Interrupts {
|
||||
ret.Interrupts = append(ret.Interrupts, InterruptContextSummary{
|
||||
Type: item.Type,
|
||||
ID: item.ID,
|
||||
InfoPreview: item.InfoPreview,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *service) tryRunSkill(ctx context.Context, req Request) (*Summary, error) {
|
||||
if req.AIAgent == nil || req.AIConfig == nil || req.UserMessage == nil || req.Conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result, err := skills.Execute(ctx, skills.RuntimeContext{
|
||||
AIAgentID: req.AIAgent.ID,
|
||||
UserMessage: strings.TrimSpace(req.UserMessage.Content),
|
||||
ConversationID: req.Conversation.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || result.Plan == nil || result.Plan.Skill == nil {
|
||||
return nil, nil
|
||||
}
|
||||
traceData := ""
|
||||
if result.RunLog != nil {
|
||||
traceData = result.RunLog.TraceData
|
||||
}
|
||||
return &Summary{
|
||||
Status: "completed",
|
||||
ReplyText: strings.TrimSpace(result.ReplyText),
|
||||
PlannedSkillCode: strings.TrimSpace(result.Plan.Skill.Code),
|
||||
PlanReason: strings.TrimSpace(result.Plan.MatchReason),
|
||||
ModelName: req.AIConfig.ModelName,
|
||||
TraceData: traceData,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/runtime/registry"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
componenttool "github.com/cloudwego/eino/components/tool"
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
einojsonschema "github.com/eino-contrib/jsonschema"
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation"
|
||||
CreateTicketConfirmToolName = "create_ticket_with_confirmation"
|
||||
)
|
||||
|
||||
type CreateTicketConfirmState struct {
|
||||
Request request.CreateTicketFromConversationRequest
|
||||
}
|
||||
|
||||
type CreateTicketConfirmInterruptInfo struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[CreateTicketConfirmState]("cs_agent_create_ticket_confirm_state")
|
||||
schema.RegisterName[CreateTicketConfirmInterruptInfo]("cs_agent_create_ticket_confirm_interrupt_info")
|
||||
}
|
||||
|
||||
type CreateTicketConfirmTool struct {
|
||||
conversation *models.Conversation
|
||||
aiAgent *models.AIAgent
|
||||
}
|
||||
|
||||
func NewCreateTicketConfirmTool() *CreateTicketConfirmTool {
|
||||
return &CreateTicketConfirmTool{}
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) Name() string {
|
||||
return CreateTicketConfirmToolName
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) Code() string {
|
||||
return CreateTicketConfirmToolCode
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) Enabled(ctx registry.Context) bool {
|
||||
return ctx.Conversation != nil && ctx.AIAgent != nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &CreateTicketConfirmTool{
|
||||
conversation: ctx.Conversation,
|
||||
aiAgent: ctx.AIAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: CreateTicketConfirmToolName,
|
||||
Desc: "当用户明确希望创建工单、投诉单、报障单,且你已经整理出工单标题和描述后,调用此工具。该工具不会立即创建工单,而是会先向用户发起确认;只有用户确认后才真正创建。不要在信息不足时调用。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
|
||||
Version: einojsonschema.Version,
|
||||
Type: "object",
|
||||
Required: []string{
|
||||
"title",
|
||||
"description",
|
||||
},
|
||||
Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "title",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "工单标题,简洁概括问题。",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "description",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "工单描述,清晰整理用户问题、现象和诉求。",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "priority",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "integer",
|
||||
Description: "工单优先级,可选;未知时可不传。",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "severity",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "integer",
|
||||
Description: "严重度,可选;1=轻微,2=严重,3=致命。",
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": CreateTicketConfirmToolCode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
if t == nil || t.conversation == nil || t.aiAgent == nil {
|
||||
return "", fmt.Errorf("ticket confirmation tool not initialized")
|
||||
}
|
||||
wasInterrupted, hasState, state := componenttool.GetInterruptState[CreateTicketConfirmState](ctx)
|
||||
if !wasInterrupted {
|
||||
req, err := t.buildCreateRequest(argumentsInJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info := CreateTicketConfirmInterruptInfo{
|
||||
Type: "ticket_creation_confirmation",
|
||||
Message: t.buildConfirmationPrompt(req),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, CreateTicketConfirmState{Request: req})
|
||||
}
|
||||
if !hasState {
|
||||
return "", fmt.Errorf("ticket confirmation state missing")
|
||||
}
|
||||
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
|
||||
if !isResumeTarget {
|
||||
info := CreateTicketConfirmInterruptInfo{
|
||||
Type: "ticket_creation_confirmation",
|
||||
Message: t.buildConfirmationPrompt(state.Request),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := CreateTicketConfirmInterruptInfo{
|
||||
Type: "ticket_creation_confirmation",
|
||||
Message: "请回复“确认”或“取消”。",
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
decision := ParseConfirmationDecision(resumeText)
|
||||
switch decision {
|
||||
case DecisionConfirm:
|
||||
item, err := services.TicketService.CreateFromConversation(state.Request, t.buildAIPrincipal())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("工单已创建,工单号:%s,标题:%s。", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)), nil
|
||||
case DecisionCancel:
|
||||
return "已取消本次工单创建。", nil
|
||||
default:
|
||||
info := CreateTicketConfirmInterruptInfo{
|
||||
Type: "ticket_creation_confirmation",
|
||||
Message: "我需要你的明确确认,请直接回复“确认”或“取消”。",
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) {
|
||||
req := request.CreateTicketFromConversationRequest{
|
||||
ConversationID: t.conversation.ID,
|
||||
SyncToConversation: true,
|
||||
}
|
||||
raw := make(map[string]any)
|
||||
if strings.TrimSpace(argumentsInJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &raw); err != nil {
|
||||
return req, fmt.Errorf("invalid create ticket arguments: %w", err)
|
||||
}
|
||||
}
|
||||
req.Title = strings.TrimSpace(getStringValue(raw, "title"))
|
||||
req.Description = strings.TrimSpace(getStringValue(raw, "description"))
|
||||
req.Priority = getInt64Value(raw, "priority")
|
||||
req.Severity = int(getInt64Value(raw, "severity"))
|
||||
if req.Title == "" {
|
||||
req.Title = strings.TrimSpace(t.conversation.Subject)
|
||||
}
|
||||
if req.Description == "" {
|
||||
req.Description = strings.TrimSpace(t.conversation.LastMessageSummary)
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
return req, fmt.Errorf("ticket title is required")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) buildConfirmationPrompt(req request.CreateTicketFromConversationRequest) string {
|
||||
return fmt.Sprintf("我准备为你创建工单。\n标题:%s\n描述:%s\n请直接回复“确认”或“取消”。",
|
||||
strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
|
||||
}
|
||||
|
||||
func (t *CreateTicketConfirmTool) buildAIPrincipal() *dto.AuthPrincipal {
|
||||
username := "AI"
|
||||
if strings.TrimSpace(t.aiAgent.Name) != "" {
|
||||
username = strings.TrimSpace(t.aiAgent.Name)
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
DecisionConfirm Decision = "confirm"
|
||||
DecisionCancel Decision = "cancel"
|
||||
)
|
||||
|
||||
func ParseConfirmationDecision(value string) Decision {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionConfirm
|
||||
}
|
||||
}
|
||||
cancelWords := []string{"取消", "不用", "不需要", "算了", "no"}
|
||||
for _, item := range cancelWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionCancel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getStringValue(data map[string]any, key string) string {
|
||||
value, ok := data[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func getInt64Value(data map[string]any, key string) int64 {
|
||||
value, ok := data[key]
|
||||
if !ok || value == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Conversation *models.Conversation
|
||||
UserMessage *models.Message
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
CheckPointID string
|
||||
ExtraTools []einotool.BaseTool
|
||||
ExtraToolCodes map[string]string
|
||||
}
|
||||
|
||||
type ResumeRequest struct {
|
||||
Conversation *models.Conversation
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
CheckPointID string
|
||||
ResumeData map[string]any
|
||||
ExtraTools []einotool.BaseTool
|
||||
ExtraToolCodes map[string]string
|
||||
}
|
||||
|
||||
type InterruptContextSummary struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InfoPreview string `json:"infoPreview,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
RunID string
|
||||
Status string
|
||||
ReplyText string
|
||||
PlannedSkillCode string
|
||||
PlanReason string
|
||||
ModelName string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
HistoryMessageCount int
|
||||
RetrieverCount int
|
||||
ToolCallCount int
|
||||
ToolCodes []string
|
||||
InvokedToolCodes []string
|
||||
CheckPointID string
|
||||
Interrupted bool
|
||||
Interrupts []InterruptContextSummary
|
||||
TraceData string
|
||||
ErrorMessage string
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/ai/mcps"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
type mcpToolExecutionConfig struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
|
||||
func executeByPlan(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext) (string, *ExecutionTrace, error) {
|
||||
if plan == nil || plan.Skill == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
trace := &ExecutionTrace{
|
||||
Status: "started",
|
||||
ExecutionMode: string(plan.Skill.ExecutionMode),
|
||||
}
|
||||
switch plan.Skill.ExecutionMode {
|
||||
case "", enums.SkillExecutionModePromptOnly:
|
||||
replyText, err := executePromptOnly(ctx, plan, runtimeCtx, trace)
|
||||
return replyText, trace, err
|
||||
case enums.SkillExecutionModeMCPTool:
|
||||
replyText, err := executeMCPTool(ctx, plan, runtimeCtx, trace)
|
||||
return replyText, trace, err
|
||||
default:
|
||||
trace.Status = "invalid_execution_mode"
|
||||
return "", trace, errorsx.InvalidParam("Skill执行模式不支持")
|
||||
}
|
||||
}
|
||||
|
||||
func executePromptOnly(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) {
|
||||
if plan == nil || plan.Skill == nil {
|
||||
return "", nil
|
||||
}
|
||||
if plan.AIConfig == nil {
|
||||
return "", errorsx.InvalidParam("Skill 关联的 AI 配置不可用")
|
||||
}
|
||||
systemPrompt := strings.TrimSpace(plan.Skill.Prompt)
|
||||
if systemPrompt == "" {
|
||||
return "", errorsx.InvalidParam("Skill Prompt 不能为空")
|
||||
}
|
||||
userPrompt := strings.TrimSpace(runtimeCtx.UserMessage)
|
||||
if userPrompt == "" {
|
||||
return "", errorsx.InvalidParam("用户消息不能为空")
|
||||
}
|
||||
promptTrace := &PromptTrace{Status: "started"}
|
||||
if trace != nil {
|
||||
trace.Prompt = promptTrace
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, err := ai.LLM.ChatWithConfig(ctx, plan.AIConfig, systemPrompt, userPrompt)
|
||||
promptTrace.LatencyMs = time.Since(startedAt).Milliseconds()
|
||||
if err != nil {
|
||||
promptTrace.Status = "error"
|
||||
promptTrace.Error = err.Error()
|
||||
if trace != nil {
|
||||
trace.Status = "error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
promptTrace.Status = "ok"
|
||||
promptTrace.ModelName = result.ModelName
|
||||
promptTrace.PromptTokens = result.PromptTokens
|
||||
promptTrace.CompletionTokens = result.CompletionTokens
|
||||
if trace != nil {
|
||||
trace.Status = "ok"
|
||||
}
|
||||
return strings.TrimSpace(result.Content), nil
|
||||
}
|
||||
|
||||
func executeMCPTool(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) {
|
||||
cfg, err := parseMCPToolExecutionConfig(plan.Skill.ExecutionConfig)
|
||||
if err != nil {
|
||||
if trace != nil {
|
||||
trace.Status = "config_error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
arguments, err := buildToolArguments(cfg.Arguments, runtimeCtx)
|
||||
if err != nil {
|
||||
if trace != nil {
|
||||
trace.Status = "argument_error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
mcpTrace := &MCPExecutionTrace{
|
||||
Status: "started",
|
||||
ServerCode: cfg.ServerCode,
|
||||
ToolName: cfg.ToolName,
|
||||
Arguments: arguments,
|
||||
}
|
||||
if trace != nil {
|
||||
trace.MCP = mcpTrace
|
||||
}
|
||||
toolStartedAt := time.Now()
|
||||
toolResult, err := mcps.Runtime.CallTool(ctx, cfg.ServerCode, cfg.ToolName, arguments)
|
||||
mcpTrace.LatencyMs = time.Since(toolStartedAt).Milliseconds()
|
||||
if err != nil {
|
||||
mcpTrace.Status = "error"
|
||||
mcpTrace.Error = err.Error()
|
||||
if trace != nil {
|
||||
trace.Status = "error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
mcpTrace.Status = "ok"
|
||||
mcpTrace.IsError = toolResult.IsError
|
||||
mcpTrace.ContentItemCount = len(toolResult.Content)
|
||||
mcpTrace.HasStructuredContent = toolResult.StructuredContent != nil
|
||||
toolSummary := buildToolSummary(toolResult)
|
||||
mcpTrace.ResultPreview = truncateTraceText(toolSummary, 500)
|
||||
if strings.TrimSpace(toolSummary) == "" {
|
||||
if trace != nil {
|
||||
trace.Status = "empty_tool_result"
|
||||
}
|
||||
return "", errorsx.InvalidParam("MCP工具未返回有效结果")
|
||||
}
|
||||
systemPrompt := strings.TrimSpace(plan.Skill.Prompt)
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = "你是客服技能助手。请依据工具结果准确回答用户问题,不要编造工具结果中不存在的事实。"
|
||||
}
|
||||
userPrompt := fmt.Sprintf("用户问题:%s\n\n工具结果:\n%s", strings.TrimSpace(runtimeCtx.UserMessage), toolSummary)
|
||||
summaryTrace := &PromptTrace{Status: "started"}
|
||||
mcpTrace.SummaryPrompt = summaryTrace
|
||||
summaryStartedAt := time.Now()
|
||||
result, err := ai.LLM.ChatWithConfig(ctx, plan.AIConfig, systemPrompt, userPrompt)
|
||||
summaryTrace.LatencyMs = time.Since(summaryStartedAt).Milliseconds()
|
||||
if err != nil {
|
||||
summaryTrace.Status = "error"
|
||||
summaryTrace.Error = err.Error()
|
||||
if trace != nil {
|
||||
trace.Status = "error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
summaryTrace.Status = "ok"
|
||||
summaryTrace.ModelName = result.ModelName
|
||||
summaryTrace.PromptTokens = result.PromptTokens
|
||||
summaryTrace.CompletionTokens = result.CompletionTokens
|
||||
if trace != nil {
|
||||
trace.Status = "ok"
|
||||
}
|
||||
return strings.TrimSpace(result.Content), nil
|
||||
}
|
||||
|
||||
func parseMCPToolExecutionConfig(raw string) (*mcpToolExecutionConfig, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errorsx.InvalidParam("ExecutionConfig不能为空")
|
||||
}
|
||||
cfg := &mcpToolExecutionConfig{}
|
||||
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
|
||||
return nil, errorsx.InvalidParam("ExecutionConfig格式不合法")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ServerCode) == "" {
|
||||
return nil, errorsx.InvalidParam("ExecutionConfig.serverCode不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ToolName) == "" {
|
||||
return nil, errorsx.InvalidParam("ExecutionConfig.toolName不能为空")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func buildToolArguments(templateArgs map[string]string, runtimeCtx RuntimeContext) (map[string]any, error) {
|
||||
if len(templateArgs) == 0 {
|
||||
return map[string]any{
|
||||
"query": strings.TrimSpace(runtimeCtx.UserMessage),
|
||||
}, nil
|
||||
}
|
||||
data := map[string]any{
|
||||
"userMessage": strings.TrimSpace(runtimeCtx.UserMessage),
|
||||
"conversationId": runtimeCtx.ConversationID,
|
||||
"aiAgentId": runtimeCtx.AIAgentID,
|
||||
"manualSkillCode": strings.TrimSpace(runtimeCtx.ManualSkillCode),
|
||||
"intentCode": strings.TrimSpace(runtimeCtx.IntentCode),
|
||||
}
|
||||
ret := make(map[string]any, len(templateArgs))
|
||||
for key, value := range templateArgs {
|
||||
rendered, err := renderTemplate(value, data)
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParam("ExecutionConfig.arguments模板不合法")
|
||||
}
|
||||
ret[key] = rendered
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func renderTemplate(raw string, data map[string]any) (string, error) {
|
||||
tpl, err := template.New("skill_arg").Option("missingkey=zero").Parse(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
func buildToolSummary(result *mcps.ToolCallResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(result.Content)+2)
|
||||
if result.StructuredContent != nil {
|
||||
if data, err := json.Marshal(result.StructuredContent); err == nil {
|
||||
lines = append(lines, string(data))
|
||||
}
|
||||
}
|
||||
for _, item := range result.Content {
|
||||
if strings.TrimSpace(item.Text) != "" {
|
||||
lines = append(lines, strings.TrimSpace(item.Text))
|
||||
continue
|
||||
}
|
||||
if item.Data != nil {
|
||||
if data, err := json.Marshal(item.Data); err == nil {
|
||||
lines = append(lines, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func truncateTraceText(raw string, limit int) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || limit <= 0 {
|
||||
return raw
|
||||
}
|
||||
runes := []rune(raw)
|
||||
if len(runes) <= limit {
|
||||
return raw
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:limit])) + "..."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
)
|
||||
|
||||
// BuildRunLog 根据执行计划与运行结果构建 Skill 运行日志。
|
||||
func BuildRunLog(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog {
|
||||
log := &models.SkillRunLog{
|
||||
ConversationID: ctx.ConversationID,
|
||||
AIAgentID: ctx.AIAgentID,
|
||||
ManualSkillCode: ctx.ManualSkillCode,
|
||||
IntentCode: ctx.IntentCode,
|
||||
UserMessage: ctx.UserMessage,
|
||||
TraceData: buildTraceData(trace),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if plan != nil {
|
||||
if plan.AIConfig != nil {
|
||||
log.AIConfigID = plan.AIConfig.ID
|
||||
log.UsedModel = plan.AIConfig.ModelName
|
||||
log.UsedProvider = plan.AIConfig.Provider
|
||||
}
|
||||
if plan.Skill != nil {
|
||||
log.SkillDefinitionID = plan.Skill.ID
|
||||
log.SkillCode = plan.Skill.Code
|
||||
log.Matched = true
|
||||
log.FinalSelected = true
|
||||
log.MatchReason = plan.MatchReason
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.ErrorMessage = err.Error()
|
||||
} else if !log.Matched {
|
||||
if plan != nil && plan.MatchReason != "" {
|
||||
log.MatchReason = plan.MatchReason
|
||||
} else {
|
||||
log.MatchReason = "not_matched"
|
||||
}
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
func buildTraceData(trace *ExecutionTrace) string {
|
||||
if trace == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(trace)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 {
|
||||
lines = append(lines, fmt.Sprintf("- skillCode=%s; name=%s; description=%s", strings.TrimSpace(item.Code), strings.TrimSpace(item.Name), strings.TrimSpace(item.Description)))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "请只输出一个 skillCode 或 NONE。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
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,94 @@
|
||||
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
|
||||
}
|
||||
|
||||
// WriteRunLog 写入 Skill 运行日志。
|
||||
func WriteRunLog(log *models.SkillRunLog) error {
|
||||
if log == nil {
|
||||
return nil
|
||||
}
|
||||
return repositories.SkillRunLogRepository.Create(sqls.DB(), log)
|
||||
}
|
||||
|
||||
// Execute 执行一次 Skill 运行,当前阶段仅支持 prompt_only 风格的手动 Skill。
|
||||
func Execute(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) {
|
||||
plan, err := BuildExecutionPlan(ctx, runtimeCtx)
|
||||
if err != nil {
|
||||
trace := &ExecutionTrace{Status: "plan_error"}
|
||||
log := BuildRunLog(runtimeCtx, nil, trace, err)
|
||||
_ = WriteRunLog(log)
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil || plan.Skill == nil {
|
||||
trace := &ExecutionTrace{Status: "noop"}
|
||||
if plan != nil {
|
||||
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
|
||||
trace.Route = plan.RouteTrace
|
||||
}
|
||||
log := BuildRunLog(runtimeCtx, plan, trace, nil)
|
||||
_ = WriteRunLog(log)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
replyText, trace, err := executeByPlan(ctx, plan, runtimeCtx)
|
||||
if trace != nil {
|
||||
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
|
||||
if trace.Route == nil {
|
||||
trace.Route = plan.RouteTrace
|
||||
}
|
||||
}
|
||||
log := BuildRunLog(runtimeCtx, plan, trace, err)
|
||||
if strings.TrimSpace(replyText) != "" && strings.TrimSpace(log.MatchReason) == "" {
|
||||
log.MatchReason = string(plan.Skill.ExecutionMode)
|
||||
}
|
||||
if writeErr := WriteRunLog(log); writeErr != nil && err == nil {
|
||||
err = writeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ExecutionResult{
|
||||
Plan: plan,
|
||||
ReplyText: strings.TrimSpace(replyText),
|
||||
RunLog: log,
|
||||
Trace: trace,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package skills
|
||||
|
||||
import "cs-agent/internal/models"
|
||||
|
||||
// RuntimeContext 表示一次 Skill 运行的输入上下文。
|
||||
type RuntimeContext struct {
|
||||
AIAgentID int64 // AIAgentID 为当前请求所属的 AI Agent ID,必填。
|
||||
UserMessage string // UserMessage 为当前用户输入。
|
||||
ConversationID int64 // ConversationID 为当前会话 ID,无会话上下文时为 0。
|
||||
ManualSkillCode string // ManualSkillCode 为显式指定的 Skill 编码。
|
||||
IntentCode string // IntentCode 为上游识别出的意图编码。
|
||||
}
|
||||
|
||||
// ExecutionPlan 表示 Skill Runtime 计算出的最终执行计划。
|
||||
type ExecutionPlan struct {
|
||||
AIAgent *models.AIAgent // AIAgent 为本次请求所属的 AI Agent。
|
||||
AIConfig *models.AIConfig // AIConfig 为本次请求实际使用的模型配置。
|
||||
Skill *models.SkillDefinition // Skill 为最终命中的 Skill,未命中时为空。
|
||||
MatchReason string // MatchReason 为命中原因。
|
||||
RouteTrace *RouteTrace // RouteTrace 为匹配阶段的路由追踪。
|
||||
}
|
||||
|
||||
// ExecutionResult 表示一次 Skill 执行的最终结果。
|
||||
type ExecutionResult struct {
|
||||
Plan *ExecutionPlan
|
||||
ReplyText string
|
||||
RunLog *models.SkillRunLog
|
||||
Trace *ExecutionTrace
|
||||
}
|
||||
|
||||
type ExecutionTrace struct {
|
||||
Status string `json:"status"`
|
||||
MatchReason string `json:"matchReason,omitempty"`
|
||||
Route *RouteTrace `json:"route,omitempty"`
|
||||
ExecutionMode string `json:"executionMode,omitempty"`
|
||||
Prompt *PromptTrace `json:"prompt,omitempty"`
|
||||
MCP *MCPExecutionTrace `json:"mcp,omitempty"`
|
||||
}
|
||||
|
||||
type RouteTrace struct {
|
||||
Status string `json:"status"`
|
||||
CandidateSkillCodes []string `json:"candidateSkillCodes,omitempty"`
|
||||
SelectedSkillCode string `json:"selectedSkillCode,omitempty"`
|
||||
RawDecision string `json:"rawDecision,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PromptTrace struct {
|
||||
Status string `json:"status"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
PromptTokens int `json:"promptTokens,omitempty"`
|
||||
CompletionTokens int `json:"completionTokens,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MCPExecutionTrace struct {
|
||||
Status string `json:"status"`
|
||||
ServerCode string `json:"serverCode,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
ContentItemCount int `json:"contentItemCount,omitempty"`
|
||||
HasStructuredContent bool `json:"hasStructuredContent,omitempty"`
|
||||
ResultPreview string `json:"resultPreview,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SummaryPrompt *PromptTrace `json:"summaryPrompt,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user