Files
ai-agent/internal/services/business_action_tool_service.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

159 lines
6.4 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"code.tczkiot.com/wlw/ai-agent/contract"
)
var BusinessActionToolService = &businessActionToolService{}
type businessActionToolService struct {
mu sync.RWMutex
tools map[string]contract.BusinessActionTool
}
func SetBusinessActionTools(tools []contract.BusinessActionTool) error {
registered := make(map[string]contract.BusinessActionTool, len(tools))
for _, tool := range tools {
tool.Code = strings.TrimSpace(tool.Code)
tool.Description = strings.TrimSpace(tool.Description)
if tool.Code == "" {
return fmt.Errorf("ai-agent: business action tool code is required")
}
if !strings.HasPrefix(tool.Code, "business/") {
return fmt.Errorf("ai-agent: business action tool code must start with business/: %s", tool.Code)
}
if tool.Description == "" {
return fmt.Errorf("ai-agent: business action tool description is required: %s", tool.Code)
}
if tool.Preview == nil || tool.Execute == nil {
return fmt.Errorf("ai-agent: business action tool preview and executor are required: %s", tool.Code)
}
if _, exists := registered[tool.Code]; exists {
return fmt.Errorf("ai-agent: duplicate business action tool code: %s", tool.Code)
}
if tool.InputSchema == nil {
tool.InputSchema = map[string]any{"type": "object", "properties": map[string]any{}}
}
registered[tool.Code] = tool
}
BusinessActionToolService.mu.Lock()
BusinessActionToolService.tools = registered
BusinessActionToolService.mu.Unlock()
return nil
}
func (s *businessActionToolService) ListForCustomerType(customerType string) []contract.BusinessActionTool {
s.mu.RLock()
defer s.mu.RUnlock()
ret := make([]contract.BusinessActionTool, 0, len(s.tools))
for _, tool := range s.tools {
if businessActionToolSupportsCustomerType(tool, customerType) {
ret = append(ret, tool)
}
}
sort.Slice(ret, func(i, j int) bool { return ret[i].Code < ret[j].Code })
return ret
}
func (s *businessActionToolService) ResolveForCustomerType(code, customerType string) (contract.BusinessActionTool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
tool, ok := s.tools[strings.TrimSpace(code)]
if !ok || !businessActionToolSupportsCustomerType(tool, customerType) {
return contract.BusinessActionTool{}, false
}
return tool, true
}
func (s *businessActionToolService) Resolve(code string) (contract.BusinessActionTool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
tool, ok := s.tools[strings.TrimSpace(code)]
return tool, ok
}
func (s *businessActionToolService) Preview(ctx context.Context, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (string, error) {
return tool.Preview(ctx, businessContext, arguments)
}
func (s *businessActionToolService) Execute(ctx context.Context, conversationID, aiAgentID int64, idempotencyKey string, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (*contract.BusinessActionResult, bool, error) {
businessContext.CheckPointID = strings.TrimSpace(idempotencyKey)
if tool.AuthorizeConfirmation != nil {
if err := tool.AuthorizeConfirmation(ctx, businessContext, arguments, businessContext.CheckPointID); err != nil {
return nil, false, err
}
}
claim, err := AgentToolInvocationService.Claim(conversationID, aiAgentID, tool.Code, idempotencyKey)
if err != nil {
return nil, false, contract.NewBusinessActionError("操作请求记录创建失败,本次操作未执行,请稍后重试。", err)
}
if claim == nil || claim.Item == nil {
err := fmt.Errorf("business action invocation could not be claimed")
return nil, false, contract.NewBusinessActionError("操作请求无效,本次操作未执行,请重新发起。", err)
}
if claim.Completed {
result := &contract.BusinessActionResult{}
if err := json.Unmarshal([]byte(claim.Item.ResultData), result); err != nil {
return nil, true, contract.NewBusinessActionError("操作已完成,但结果读取失败,请勿重复操作并联系人工客服核对。", err)
}
return result, true, nil
}
if claim.UnknownOutcome {
err := fmt.Errorf("business action outcome requires reconciliation: %s", tool.Code)
return nil, true, contract.NewUnknownOutcomeBusinessActionError("上次操作结果尚未确认,请勿重复操作,并联系人工客服核对。", err)
}
if !claim.Acquired {
err := fmt.Errorf("business action is already running: %s", tool.Code)
return nil, false, contract.NewBusinessActionError("操作正在处理中,请勿重复提交,请稍后查看结果。", err)
}
result, err := tool.Execute(ctx, businessContext, arguments)
if err != nil {
if businessActionFailureIsRetryable(ctx, err) {
_ = AgentToolInvocationService.FailRetryable(claim.Item, err)
} else {
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
}
return nil, false, err
}
if result == nil || strings.TrimSpace(result.Message) == "" {
err = fmt.Errorf("business action returned an empty result: %s", tool.Code)
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
return nil, false, contract.NewBusinessActionError("业务系统未返回操作结果,本次操作未完成,请联系人工客服核对。", err)
}
encoded, err := json.Marshal(result)
if err != nil {
// The host operation has already completed. Persist at least the customer
// message and never mark the invocation retryable, which could execute the
// same paid action twice.
encoded, _ = json.Marshal(&contract.BusinessActionResult{Message: result.Message})
}
if err := AgentToolInvocationService.Complete(claim.Item, string(encoded)); err != nil {
// The external write may already have committed. Never leave the invocation
// eligible for replay; persist an explicit reconciliation state whenever the
// result cannot be durably recorded.
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
return nil, false, contract.NewBusinessActionError("业务操作可能已经成功,但结果记录失败。请勿重复操作,并联系人工客服核对。", err)
}
return result, false, nil
}
func businessActionToolSupportsCustomerType(tool contract.BusinessActionTool, customerType string) bool {
if len(tool.CustomerTypes) == 0 {
return true
}
for _, candidate := range tool.CustomerTypes {
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) {
return true
}
}
return false
}