feat(captain): align document response actions
This commit is contained in:
@@ -560,7 +560,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
|
||||
// Captain services (P10 M10 — Captain AI + Copilot)
|
||||
captainAssistantService := service.NewCaptainAssistantService(captainAssistantRepo, captainInboxRepo, captainDocumentRepo, captainAssistantResponseRepo, llmProvider)
|
||||
captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider)
|
||||
captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider, captainAssistantRepo)
|
||||
captainScenarioService := service.NewCaptainScenarioService(captainScenarioRepo, captainAssistantRepo)
|
||||
captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo)
|
||||
copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider)
|
||||
@@ -571,6 +571,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
captainTaskExtendedService := service.NewCaptainTaskExtendedService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider)
|
||||
captainAssistantResponseService := service.NewCaptainAssistantResponseService(captainAssistantRepo, captainAssistantResponseRepo, conversationRepo, messageRepo, captainPreferenceRepo, llmProvider)
|
||||
captainBulkActionService := service.NewCaptainBulkActionService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, captainTaskExtendedService, captainAssistantResponseService)
|
||||
captainBulkActionService.SetCaptainResourceRepos(captainAssistantResponseRepo, captainDocumentRepo)
|
||||
|
||||
// Analytics services (P11 — Reports/Analytics)
|
||||
analyticsService := service.NewAnalyticsService(reportingEventRepo, reportingEventsRollupRepo)
|
||||
|
||||
@@ -2,9 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
@@ -23,8 +23,8 @@ func NewCaptainAssistantResponseHandler(svc *service.CaptainAssistantResponseSer
|
||||
// ProcessResponse generates and optionally stores an assistant response.
|
||||
// POST /api/v1/accounts/:id/captain/assistant_responses
|
||||
func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ProcessResponse(c.Request.Context(), uint(accountID), &req)
|
||||
result, err := h.svc.ProcessResponse(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Process assistant response: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to process assistant response")
|
||||
@@ -49,60 +49,64 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
|
||||
// GET /api/v1/accounts/:id/captain/assistant_responses
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#index
|
||||
func (h *CaptainAssistantResponseHandler) List(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("per_page", "25"))
|
||||
assistantID, _ := strconv.ParseUint(c.Query("assistant_id"), 10, 64)
|
||||
documentID, _ := strconv.ParseUint(c.Query("document_id"), 10, 64)
|
||||
page, _ := parseIntQueryDefault(c, "page", 1)
|
||||
pageSize := 25
|
||||
assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id")
|
||||
documentID, _ := parseOptionalUintQueryParam(c, "document_id")
|
||||
status := c.Query("status")
|
||||
search := c.Query("search")
|
||||
|
||||
responses, total, err := h.svc.List(c.Request.Context(), uint(accountID), uint(assistantID), uint(documentID), status, search, page, pageSize)
|
||||
responses, total, err := h.svc.List(c.Request.Context(), accountID, assistantID, documentID, status, search, page, pageSize)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List assistant responses: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list responses")
|
||||
return
|
||||
}
|
||||
response.OKWithMeta(c, responses, page, pageSize, total)
|
||||
payload := make([]gin.H, 0, len(responses))
|
||||
for i := range responses {
|
||||
payload = append(payload, captainAssistantResponsePayload(&responses[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": total, "page": page}})
|
||||
}
|
||||
|
||||
// Get returns a single assistant response.
|
||||
// GET /api/v1/accounts/:id/captain/assistant_responses/:response_id
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#show
|
||||
func (h *CaptainAssistantResponseHandler) Get(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
|
||||
responseID, err := parseUintParam(c, "response_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
|
||||
return
|
||||
}
|
||||
resp, err := h.svc.Get(c.Request.Context(), uint(accountID), uint(responseID))
|
||||
resp, err := h.svc.Get(c.Request.Context(), accountID, responseID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get assistant response: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "response not found")
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
|
||||
}
|
||||
|
||||
// Update modifies an assistant response.
|
||||
// PUT /api/v1/accounts/:id/captain/assistant_responses/:response_id
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#update
|
||||
func (h *CaptainAssistantResponseHandler) Update(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
|
||||
responseID, err := parseUintParam(c, "response_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
|
||||
return
|
||||
@@ -112,34 +116,34 @@ func (h *CaptainAssistantResponseHandler) Update(c *gin.Context) {
|
||||
Answer string `json:"answer"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.svc.Update(c.Request.Context(), uint(accountID), uint(responseID), req.Question, req.Answer, req.Status)
|
||||
resp, err := h.svc.Update(c.Request.Context(), accountID, responseID, req.Question, req.Answer, req.Status)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Update assistant response: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update response")
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
|
||||
}
|
||||
|
||||
// Delete removes an assistant response.
|
||||
// DELETE /api/v1/accounts/:id/captain/assistant_responses/:response_id
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#destroy (head :no_content)
|
||||
func (h *CaptainAssistantResponseHandler) Delete(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
|
||||
responseID, err := parseUintParam(c, "response_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), uint(accountID), uint(responseID)); err != nil {
|
||||
if err := h.svc.Delete(c.Request.Context(), accountID, responseID); err != nil {
|
||||
applogger.L().Errorf("Delete assistant response: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete response")
|
||||
return
|
||||
@@ -152,16 +156,12 @@ func (h *CaptainAssistantResponseHandler) Delete(c *gin.Context) {
|
||||
// POST /api/v1/accounts/:id/captain/assistant_responses
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#create
|
||||
func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Question string `json:"question" validate:"required"`
|
||||
@@ -169,16 +169,49 @@ func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) {
|
||||
AssistantID uint `json:"assistant_id" validate:"required"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.svc.Create(c.Request.Context(), uint(accountID), userID, req.AssistantID, req.Question, req.Answer, req.Status)
|
||||
resp, err := h.svc.Create(c.Request.Context(), accountID, userID, req.AssistantID, req.Question, req.Answer, req.Status)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create assistant response: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create response")
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
|
||||
}
|
||||
|
||||
func captainAssistantResponsePayload(resp *model.CaptainAssistantResponse) gin.H {
|
||||
payload := gin.H{
|
||||
"account_id": resp.AccountID,
|
||||
"answer": resp.Answer,
|
||||
"assistant": captainResponseAssistantPayload(resp),
|
||||
"created_at": resp.CreatedAt.Unix(),
|
||||
"id": resp.ID,
|
||||
"question": resp.Question,
|
||||
"updated_at": resp.UpdatedAt.Unix(),
|
||||
"status": resp.Status,
|
||||
"edited": resp.Edited,
|
||||
}
|
||||
if resp.DocumentableID != nil {
|
||||
payload["documentable"] = captainResponseDocumentablePayload(resp)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func captainResponseAssistantPayload(resp *model.CaptainAssistantResponse) gin.H {
|
||||
if resp.Assistant.ID == 0 {
|
||||
return gin.H{"id": resp.AssistantID}
|
||||
}
|
||||
return captainAssistantPayload(&resp.Assistant)
|
||||
}
|
||||
|
||||
func captainResponseDocumentablePayload(resp *model.CaptainAssistantResponse) gin.H {
|
||||
payload := gin.H{"type": resp.DocumentableType, "id": *resp.DocumentableID}
|
||||
if resp.DocumentableType == "Conversation" {
|
||||
payload["display_id"] = *resp.DocumentableID
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
@@ -23,14 +23,26 @@ func NewCaptainBulkActionHandler(svc *service.CaptainBulkActionService) *Captain
|
||||
// Execute performs a bulk AI action on multiple conversations.
|
||||
// POST /api/v1/accounts/:id/captain/bulk_actions
|
||||
func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var chatwootReq service.ChatwootBulkActionRequest
|
||||
if err := json.Unmarshal(body, &chatwootReq); err == nil && chatwootReq.Type != "" {
|
||||
h.executeChatwoot(c, accountID, &chatwootReq)
|
||||
return
|
||||
}
|
||||
|
||||
var req service.BulkActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -51,7 +63,7 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Execute(c.Request.Context(), uint(accountID), &req)
|
||||
result, err := h.svc.Execute(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Bulk action: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to execute bulk action")
|
||||
@@ -60,3 +72,30 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *CaptainBulkActionHandler) executeChatwoot(c *gin.Context, accountID uint, req *service.ChatwootBulkActionRequest) {
|
||||
result, err := h.svc.ExecuteChatwoot(c.Request.Context(), accountID, req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Chatwoot bulk action: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
if result.Empty {
|
||||
c.JSON(http.StatusOK, []gin.H{})
|
||||
return
|
||||
}
|
||||
if result.AssistantResponses != nil {
|
||||
payload := make([]gin.H, 0, len(result.AssistantResponses))
|
||||
for i := range result.AssistantResponses {
|
||||
payload = append(payload, captainAssistantResponsePayload(&result.AssistantResponses[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
if result.IDs != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ids": result.IDs, "count": result.Count})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": result.Count})
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) {
|
||||
result, err := h.svc.TestTool(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("TestTool: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to test tool")
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -54,13 +54,13 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
|
||||
s.Require().NoError(db.Create(account).Error)
|
||||
s.account = account
|
||||
|
||||
// 创建测试自定义工具(指向 httpbin.org 的 GET 端点,测试时替换为本地mock)
|
||||
// 创建测试自定义工具
|
||||
tool := &model.CaptainCustomTool{
|
||||
AccountID: account.ID,
|
||||
Title: "测试工具",
|
||||
Slug: "test-tool",
|
||||
Description: "用于测试的工具",
|
||||
EndpointURL: "https://httpbin.org/get",
|
||||
EndpointURL: "http://tool.test",
|
||||
HTTPMethod: "GET",
|
||||
AuthType: model.ToolAuthTypeNone,
|
||||
Enabled: true,
|
||||
@@ -71,6 +71,9 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
|
||||
// 创建 repo + service + handler
|
||||
toolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
svc := service.NewCaptainCustomToolService(toolRepo)
|
||||
svc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
}))
|
||||
s.handler = NewCaptainCustomToolHandler(svc)
|
||||
|
||||
// 设置路由
|
||||
@@ -108,16 +111,11 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_成功测试工具(
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
// httpbin.org 可能不可达,所以允许成功或失败
|
||||
// 关键是接口返回正确格式
|
||||
if w.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(s.T(), resp, "success")
|
||||
} else {
|
||||
// 即使外部请求失败,也应该返回 InternalServerError 格式
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(s.T(), float64(http.StatusCreated), resp["status"])
|
||||
assert.Contains(s.T(), resp, "body")
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_无效accountID() {
|
||||
@@ -162,7 +160,6 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_不存在的工具I
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
// 不存在的工具ID应返回500
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
@@ -172,7 +169,7 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参
|
||||
AccountID: s.account.ID,
|
||||
Title: "POST测试工具",
|
||||
Slug: "post-test-tool",
|
||||
EndpointURL: "https://httpbin.org/post",
|
||||
EndpointURL: "http://tool.test",
|
||||
HTTPMethod: "POST",
|
||||
AuthType: model.ToolAuthTypeNone,
|
||||
RequestTemplate: "{\"message\": \"{{.message}}\"}",
|
||||
@@ -193,12 +190,10 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
// httpbin.org 可能不可达,关键检查返回格式
|
||||
if w.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(s.T(), resp, "success")
|
||||
}
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(s.T(), float64(http.StatusCreated), resp["status"])
|
||||
}
|
||||
|
||||
func TestCaptainCustomToolTestHandlerSuite(t *testing.T) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -25,50 +25,58 @@ func NewCaptainDocumentHandler(svc *service.CaptainDocumentService) *CaptainDocu
|
||||
// Create creates a new captain document.
|
||||
// POST /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents
|
||||
func (h *CaptainDocumentHandler) Create(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id")
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := bindNestedJSONPayload(c, "document", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.svc.Create(c.Request.Context(), uint(assistantID), uint(accountID), &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create captain document: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create document")
|
||||
assistantID := req.AssistantID
|
||||
if assistantID == 0 {
|
||||
assistantID, _ = parseUintParam(c, "assistant_id")
|
||||
}
|
||||
if assistantID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "Missing Assistant")
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, doc)
|
||||
doc, err := h.svc.Create(c.Request.Context(), assistantID, accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create captain document: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "failed to create document")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, captainDocumentPayload(doc))
|
||||
}
|
||||
|
||||
// Get retrieves a captain document by ID.
|
||||
// GET /api/v1/accounts/:account_id/captain_documents/:id
|
||||
func (h *CaptainDocumentHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "document_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.svc.Get(c.Request.Context(), uint(id))
|
||||
doc, err := h.svc.GetByAccount(c.Request.Context(), accountID, id)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get captain document: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, doc)
|
||||
c.JSON(http.StatusOK, captainDocumentPayload(doc))
|
||||
}
|
||||
|
||||
// Update updates an existing captain document.
|
||||
@@ -99,15 +107,20 @@ func (h *CaptainDocumentHandler) Update(c *gin.Context) {
|
||||
// Delete deletes a captain document.
|
||||
// DELETE /api/v1/accounts/:account_id/captain_documents/:id
|
||||
func (h *CaptainDocumentHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "document_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil {
|
||||
if err := h.svc.DeleteByAccount(c.Request.Context(), accountID, id); err != nil {
|
||||
applogger.L().Errorf("Delete captain document: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete document")
|
||||
response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to delete document")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,21 +130,36 @@ func (h *CaptainDocumentHandler) Delete(c *gin.Context) {
|
||||
// List retrieves documents for an assistant.
|
||||
// GET /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents
|
||||
func (h *CaptainDocumentHandler) List(c *gin.Context) {
|
||||
assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id")
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
p := pagination.Parse(c)
|
||||
docs, count, err := h.svc.List(c.Request.Context(), uint(assistantID), p.Offset, p.PerPage)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id")
|
||||
if assistantID == 0 {
|
||||
assistantID, _ = parseUintParam(c, "assistant_id")
|
||||
}
|
||||
docs, count, currentPage, err := h.svc.ListByAccount(c.Request.Context(), accountID, service.ListDocumentsRequest{
|
||||
AssistantID: assistantID,
|
||||
Page: page,
|
||||
PerPage: 25,
|
||||
Filter: c.Query("filter"),
|
||||
Source: c.Query("source"),
|
||||
Sort: c.Query("sort"),
|
||||
SearchKey: c.Query("search_key"),
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List captain documents: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list documents")
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, docs, p.Page, p.PerPage, count)
|
||||
payload := make([]gin.H, 0, len(docs))
|
||||
for i := range docs {
|
||||
payload = append(payload, captainDocumentPayload(&docs[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": currentPage}})
|
||||
}
|
||||
|
||||
// ProcessDocument triggers document content extraction and embedding generation.
|
||||
@@ -155,17 +183,68 @@ func (h *CaptainDocumentHandler) ProcessDocument(c *gin.Context) {
|
||||
// SyncDocument triggers re-fetching content from the external URL.
|
||||
// POST /api/v1/accounts/:account_id/captain_documents/:id/sync
|
||||
func (h *CaptainDocumentHandler) SyncDocument(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "document_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SyncDocument(c.Request.Context(), uint(id)); err != nil {
|
||||
if _, err := h.svc.MarkSyncing(c.Request.Context(), accountID, id); err != nil {
|
||||
applogger.L().Errorf("SyncDocument: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to sync document")
|
||||
response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to sync document")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{"synced": true})
|
||||
}
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func captainDocumentPayload(doc *model.CaptainDocument) gin.H {
|
||||
status := doc.Status
|
||||
if status == "" {
|
||||
status = model.DocumentStatusPending
|
||||
}
|
||||
syncStatus := doc.SyncStatus
|
||||
if syncStatus == model.DocumentSyncStatusPending {
|
||||
syncStatus = "syncing"
|
||||
}
|
||||
payload := gin.H{
|
||||
"account_id": doc.AccountID,
|
||||
"assistant": captainDocumentAssistantPayload(doc),
|
||||
"content": doc.Content,
|
||||
"content_type": "text/html",
|
||||
"created_at": doc.CreatedAt.Unix(),
|
||||
"external_link": doc.ExternalLink,
|
||||
"display_url": doc.ExternalLink,
|
||||
"file_size": 0,
|
||||
"pdf_document": false,
|
||||
"id": doc.ID,
|
||||
"name": doc.Name,
|
||||
"status": status,
|
||||
"sync_status": syncStatus,
|
||||
"sync_in_progress": syncStatus == "syncing" || syncStatus == model.DocumentSyncStatusPending,
|
||||
"last_synced_at": int64PointerValue(doc.LastSyncedAt),
|
||||
"last_sync_attempted_at": int64PointerValue(doc.LastSyncAttemptedAt),
|
||||
"last_sync_error_code": doc.LastSyncErrorCode,
|
||||
"updated_at": doc.UpdatedAt.Unix(),
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func captainDocumentAssistantPayload(doc *model.CaptainDocument) gin.H {
|
||||
if doc.Assistant.ID == 0 {
|
||||
return gin.H{"id": doc.AssistantID}
|
||||
}
|
||||
return captainAssistantPayload(&doc.Assistant)
|
||||
}
|
||||
|
||||
func int64PointerValue(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,6 +21,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeCaptainToolHTTPDoer func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f fakeCaptainToolHTTPDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Account, *model.CaptainAssistant) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -28,6 +36,8 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.CaptainAssistant{},
|
||||
&model.CaptainDocument{},
|
||||
&model.CaptainAssistantResponse{},
|
||||
&model.CaptainScenario{},
|
||||
&model.CaptainCustomTool{},
|
||||
))
|
||||
@@ -50,7 +60,19 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
|
||||
|
||||
toolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
toolSvc := service.NewCaptainCustomToolService(toolRepo)
|
||||
toolSvc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: make(http.Header)}, nil
|
||||
}))
|
||||
toolHandler := NewCaptainCustomToolHandler(toolSvc)
|
||||
documentRepo := repository.NewCaptainDocumentRepo(db)
|
||||
documentSvc := service.NewCaptainDocumentService(documentRepo, nil, assistantRepo)
|
||||
documentHandler := NewCaptainDocumentHandler(documentSvc)
|
||||
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
||||
responseSvc := service.NewCaptainAssistantResponseService(assistantRepo, responseRepo, nil, nil, nil, nil)
|
||||
responseHandler := NewCaptainAssistantResponseHandler(responseSvc)
|
||||
bulkSvc := service.NewCaptainBulkActionService(nil, nil, assistantRepo, nil, nil, nil, responseSvc)
|
||||
bulkSvc.SetCaptainResourceRepos(responseRepo, documentRepo)
|
||||
bulkHandler := NewCaptainBulkActionHandler(bulkSvc)
|
||||
|
||||
router := gin.New()
|
||||
accountGroup := router.Group("/api/v1/accounts/:account_id/captain")
|
||||
@@ -62,12 +84,30 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
|
||||
assistantScenarios.DELETE("/:scenario_id", scenarioHandler.Delete)
|
||||
|
||||
customTools := accountGroup.Group("/custom_tools")
|
||||
customTools.POST("/test", toolHandler.TestTool)
|
||||
customTools.GET("/", toolHandler.List)
|
||||
customTools.POST("/", toolHandler.Create)
|
||||
customTools.GET("/:tool_id", toolHandler.Get)
|
||||
customTools.PUT("/:tool_id", toolHandler.Update)
|
||||
customTools.DELETE("/:tool_id", toolHandler.Delete)
|
||||
|
||||
documents := accountGroup.Group("/documents")
|
||||
documents.GET("/", documentHandler.List)
|
||||
documents.POST("/", documentHandler.Create)
|
||||
documents.GET("/:document_id", documentHandler.Get)
|
||||
documents.DELETE("/:document_id", documentHandler.Delete)
|
||||
documents.POST("/:document_id/sync", documentHandler.SyncDocument)
|
||||
|
||||
assistantResponses := accountGroup.Group("/assistant_responses")
|
||||
assistantResponses.GET("/", responseHandler.List)
|
||||
assistantResponses.POST("/", responseHandler.Create)
|
||||
assistantResponses.GET("/:response_id", responseHandler.Get)
|
||||
assistantResponses.PUT("/:response_id", responseHandler.Update)
|
||||
assistantResponses.DELETE("/:response_id", responseHandler.Delete)
|
||||
|
||||
bulkActions := accountGroup.Group("/bulk_actions")
|
||||
bulkActions.POST("/", bulkHandler.Execute)
|
||||
|
||||
return router, db, account, otherAccount, assistant
|
||||
}
|
||||
|
||||
@@ -179,3 +219,149 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) {
|
||||
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, toolID), nil)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) {
|
||||
router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t)
|
||||
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/documents"
|
||||
otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/documents"
|
||||
|
||||
body := map[string]any{"document": map[string]any{
|
||||
"name": "Help center",
|
||||
"external_link": "https://example.com/help",
|
||||
"assistant_id": assistant.ID,
|
||||
}}
|
||||
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
|
||||
assert.NotContains(t, created, "success")
|
||||
assert.Equal(t, "Help center", created["name"])
|
||||
assert.Equal(t, float64(account.ID), created["account_id"])
|
||||
assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"])
|
||||
documentID := uint(created["id"].(float64))
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?assistant_id="+strconv.FormatUint(uint64(assistant.ID), 10), nil)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp))
|
||||
assert.Len(t, listResp["payload"], 1)
|
||||
assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"])
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, documentID), nil)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodPost, fmt.Sprintf("%s/%d/sync", basePath, documentID), nil)
|
||||
assert.Equal(t, http.StatusAccepted, w.Code)
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", basePath, documentID), nil)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var synced map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &synced))
|
||||
assert.Equal(t, "syncing", synced["sync_status"])
|
||||
assert.Equal(t, true, synced["sync_in_progress"])
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, documentID), nil)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *testing.T) {
|
||||
router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t)
|
||||
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistant_responses"
|
||||
otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistant_responses"
|
||||
|
||||
body := map[string]any{"assistant_response": map[string]any{
|
||||
"question": "Where is my order?",
|
||||
"answer": "It ships today.",
|
||||
"assistant_id": assistant.ID,
|
||||
"status": "pending",
|
||||
}}
|
||||
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
|
||||
assert.NotContains(t, created, "success")
|
||||
assert.Equal(t, "pending", created["status"])
|
||||
assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"])
|
||||
responseID := uint(created["id"].(float64))
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?status=pending&search=order", nil)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp))
|
||||
assert.Len(t, listResp["payload"], 1)
|
||||
assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"])
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, responseID), nil)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
updateBody := map[string]any{"assistant_response": map[string]any{"answer": "It shipped.", "status": "approved"}}
|
||||
w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, responseID), updateBody)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var updated map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated))
|
||||
assert.Equal(t, "approved", updated["status"])
|
||||
assert.Equal(t, true, updated["edited"])
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, responseID), nil)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
func TestCaptainBulkActionHandler_ChatwootResourceActions(t *testing.T) {
|
||||
router, db, account, _, assistant := setupCaptainResourceParityTest(t)
|
||||
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/bulk_actions"
|
||||
|
||||
resp := &model.CaptainAssistantResponse{AccountID: account.ID, AssistantID: assistant.ID, Question: "Q", Answer: "A", Status: model.ResponseStatusPending}
|
||||
require.NoError(t, db.Create(resp).Error)
|
||||
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{
|
||||
"type": "AssistantResponse",
|
||||
"ids": []uint{resp.ID},
|
||||
"fields": map[string]any{
|
||||
"status": "approve",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var approved []map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &approved))
|
||||
require.Len(t, approved, 1)
|
||||
assert.Equal(t, "approved", approved[0]["status"])
|
||||
|
||||
doc := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "Doc", ExternalLink: "https://example.com", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced}
|
||||
require.NoError(t, db.Create(doc).Error)
|
||||
w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{
|
||||
"type": "AssistantDocument",
|
||||
"ids": []uint{doc.ID},
|
||||
"fields": map[string]any{
|
||||
"status": "sync",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var syncResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &syncResp))
|
||||
assert.Equal(t, float64(1), syncResp["count"])
|
||||
|
||||
w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{"type": "Unknown", "ids": []uint{1}, "fields": map[string]any{"status": "delete"}})
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
var invalid map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &invalid))
|
||||
assert.Equal(t, false, invalid["success"])
|
||||
}
|
||||
|
||||
func TestCaptainCustomToolHandler_ChatwootTestToolPayload(t *testing.T) {
|
||||
router, _, account, _, _ := setupCaptainResourceParityTest(t)
|
||||
|
||||
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/custom_tools/test"
|
||||
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath, map[string]any{"custom_tool": map[string]any{
|
||||
"title": "Tester",
|
||||
"endpoint_url": "http://tool.test",
|
||||
"http_method": "POST",
|
||||
"auth_type": "none",
|
||||
"request_template": `{"value":"{{.value}}"}`,
|
||||
"params": map[string]any{"value": "ping"},
|
||||
}})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, float64(http.StatusCreated), resp["status"])
|
||||
assert.Equal(t, `{"ok":true}`, resp["body"])
|
||||
assert.NotContains(t, resp, "success")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,30 @@ func parseUintParam(c *gin.Context, param string) (uint, error) {
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
func parseOptionalUintQueryParam(c *gin.Context, param string) (uint, error) {
|
||||
val := c.Query(param)
|
||||
if val == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.ParseUint(val, 10, 32)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
func parseIntQueryDefault(c *gin.Context, param string, fallback int) (int, error) {
|
||||
val := c.Query(param)
|
||||
if val == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseUintAnyParam(c *gin.Context, params ...string) (uint, error) {
|
||||
var lastErr error
|
||||
for _, param := range params {
|
||||
|
||||
@@ -184,6 +184,7 @@ type CaptainDocument struct {
|
||||
Metadata json.RawMessage `gorm:"type:jsonb;serializer:json" json:"metadata,omitempty"`
|
||||
|
||||
// Relationships
|
||||
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
|
||||
Responses []CaptainAssistantResponse `gorm:"foreignKey:DocumentableID" json:"responses,omitempty"`
|
||||
}
|
||||
|
||||
@@ -210,6 +211,8 @@ type CaptainAssistantResponse struct {
|
||||
Edited bool `gorm:"default:false;not null" json:"edited"`
|
||||
// pgvector-go Vector type for 1536-dimensional embeddings
|
||||
Embedding pgvector.Vector `gorm:"type:vector(1536)" json:"embedding,omitempty"`
|
||||
|
||||
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
|
||||
}
|
||||
|
||||
func (CaptainAssistantResponse) TableName() string { return "captain_assistant_responses" }
|
||||
|
||||
@@ -27,7 +27,15 @@ func (r *CaptainAssistantResponseRepo) Create(ctx context.Context, resp *model.C
|
||||
|
||||
func (r *CaptainAssistantResponseRepo) GetByID(ctx context.Context, id uint) (*model.CaptainAssistantResponse, error) {
|
||||
var resp model.CaptainAssistantResponse
|
||||
if err := r.db.WithContext(ctx).First(&resp, id).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Preload("Assistant").First(&resp, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (r *CaptainAssistantResponseRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.CaptainAssistantResponse, error) {
|
||||
var resp model.CaptainAssistantResponse
|
||||
if err := r.db.WithContext(ctx).Preload("Assistant").Where("account_id = ? AND id = ?", accountID, id).First(&resp).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
@@ -41,6 +49,10 @@ func (r *CaptainAssistantResponseRepo) Delete(ctx context.Context, id uint) erro
|
||||
return r.db.WithContext(ctx).Delete(&model.CaptainAssistantResponse{}, id).Error
|
||||
}
|
||||
|
||||
func (r *CaptainAssistantResponseRepo) DeleteByAccount(ctx context.Context, accountID, id uint) error {
|
||||
return r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).Delete(&model.CaptainAssistantResponse{}).Error
|
||||
}
|
||||
|
||||
func (r *CaptainAssistantResponseRepo) ListByAssistant(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainAssistantResponse, int64, error) {
|
||||
var responses []model.CaptainAssistantResponse
|
||||
var count int64
|
||||
@@ -82,4 +94,4 @@ func (r *CaptainAssistantResponseRepo) SimilaritySearch(ctx context.Context, ass
|
||||
// SearchByEmbedding is an alias for SimilaritySearch for backward compatibility.
|
||||
func (r *CaptainAssistantResponseRepo) SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) {
|
||||
return r.SimilaritySearch(ctx, assistantID, embedding, limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/gorm"
|
||||
@@ -16,13 +17,25 @@ func NewCaptainDocumentRepo(db *gorm.DB) *CaptainDocumentRepo {
|
||||
return &CaptainDocumentRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) Create(ctx context.Context, doc *model.CaptainDocument) error {
|
||||
return r.db.WithContext(ctx).Create(doc).Error
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) GetByID(ctx context.Context, id uint) (*model.CaptainDocument, error) {
|
||||
var doc model.CaptainDocument
|
||||
if err := r.db.WithContext(ctx).First(&doc, id).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Preload("Assistant").First(&doc, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) {
|
||||
var doc model.CaptainDocument
|
||||
if err := r.db.WithContext(ctx).Preload("Assistant").Where("account_id = ? AND id = ?", accountID, id).First(&doc).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
@@ -36,12 +49,67 @@ func (r *CaptainDocumentRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.CaptainDocument{}, id).Error
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) DeleteByAccount(ctx context.Context, accountID, id uint) error {
|
||||
return r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).Delete(&model.CaptainDocument{}).Error
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) ListByAssistant(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainDocument, int64, error) {
|
||||
var docs []model.CaptainDocument
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.CaptainDocument{}).Where("assistant_id = ?", assistantID)
|
||||
db.Count(&count)
|
||||
if err := db.Offset(offset).Limit(limit).Find(&docs).Error; err != nil {
|
||||
if err := db.Preload("Assistant").Offset(offset).Limit(limit).Find(&docs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return docs, count, nil
|
||||
}
|
||||
|
||||
type CaptainDocumentListFilters struct {
|
||||
AssistantID uint
|
||||
Source string
|
||||
Filter string
|
||||
SearchKey string
|
||||
Sort string
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (r *CaptainDocumentRepo) ListByAccount(ctx context.Context, accountID uint, filters CaptainDocumentListFilters) ([]model.CaptainDocument, int64, error) {
|
||||
var docs []model.CaptainDocument
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.CaptainDocument{}).Where("account_id = ?", accountID)
|
||||
if filters.AssistantID > 0 {
|
||||
db = db.Where("assistant_id = ?", filters.AssistantID)
|
||||
}
|
||||
switch filters.Source {
|
||||
case "web":
|
||||
db = db.Where("external_link <> ''")
|
||||
case "pdf":
|
||||
db = db.Where("external_link = ''")
|
||||
}
|
||||
switch filters.Filter {
|
||||
case "syncing":
|
||||
db = db.Where("sync_status = ?", model.DocumentSyncStatusPending)
|
||||
case "failed":
|
||||
db = db.Where("sync_status = ?", model.DocumentSyncStatusFailed)
|
||||
case "synced":
|
||||
db = db.Where("sync_status = ?", model.DocumentSyncStatusSynced)
|
||||
}
|
||||
if filters.SearchKey != "" {
|
||||
query := "%" + strings.ToLower(filters.SearchKey) + "%"
|
||||
db = db.Where("LOWER(name) LIKE ? OR LOWER(external_link) LIKE ?", query, query)
|
||||
}
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := "updated_at DESC"
|
||||
if filters.Sort == "recently_created" {
|
||||
order = "created_at DESC"
|
||||
}
|
||||
if filters.Limit <= 0 {
|
||||
filters.Limit = 25
|
||||
}
|
||||
if err := db.Preload("Assistant").Order(order).Offset(filters.Offset).Limit(filters.Limit).Find(&docs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return docs, count, nil
|
||||
@@ -65,4 +133,4 @@ func (r *CaptainDocumentRepo) FindByStatus(ctx context.Context, assistantID uint
|
||||
return nil, 0, err
|
||||
}
|
||||
return docs, count, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
||||
)
|
||||
|
||||
// CaptainAssistantResponseService processes assistant responses for conversations.
|
||||
@@ -48,17 +48,17 @@ func NewCaptainAssistantResponseService(
|
||||
type ProcessResponseRequest struct {
|
||||
ConversationID uint `json:"conversation_id" validate:"required"`
|
||||
AssistantID uint `json:"assistant_id" validate:"required"`
|
||||
SendMessage bool `json:"send_message"` // if true, store response as outgoing message
|
||||
ContentHint string `json:"content_hint,omitempty"` // optional context for response generation
|
||||
SendMessage bool `json:"send_message"` // if true, store response as outgoing message
|
||||
ContentHint string `json:"content_hint,omitempty"` // optional context for response generation
|
||||
}
|
||||
|
||||
// ProcessResponseResult holds the generated response and its storage status.
|
||||
type ProcessResponseResult struct {
|
||||
Content string `json:"content"`
|
||||
MessageID uint `json:"message_id,omitempty"` // set if send_message was true
|
||||
MessageID uint `json:"message_id,omitempty"` // set if send_message was true
|
||||
AssistantID uint `json:"assistant_id"`
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
Sent bool `json:"sent"` // true if the message was stored
|
||||
Sent bool `json:"sent"` // true if the message was stored
|
||||
}
|
||||
|
||||
// --- Business logic ---
|
||||
@@ -136,7 +136,7 @@ Keep your response concise (max ~%d characters).
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "",
|
||||
Model: "",
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: conversationContext},
|
||||
@@ -205,7 +205,7 @@ func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID ui
|
||||
}
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
db = db.Where("question ILIKE ? OR answer ILIKE ?", searchTerm, searchTerm)
|
||||
db = db.Where("question LIKE ? OR answer LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Eager load assistant and documentable — matches Chatwoot includes(:assistant, :documentable)
|
||||
@@ -227,13 +227,10 @@ func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID ui
|
||||
// Get returns a single assistant response by ID.
|
||||
// Reference: Chatwoot Captain::AssistantResponsesController#show
|
||||
func (s *CaptainAssistantResponseService) Get(ctx context.Context, accountID uint, responseID uint) (*model.CaptainAssistantResponse, error) {
|
||||
resp, err := s.responseRepo.GetByID(ctx, responseID)
|
||||
resp, err := s.responseRepo.GetByAccountAndID(ctx, accountID, responseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get response: %w", err)
|
||||
}
|
||||
if resp.AccountID != accountID {
|
||||
return nil, fmt.Errorf("response does not belong to account")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -269,6 +266,9 @@ func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID
|
||||
if err := s.responseRepo.Create(ctx, resp); err != nil {
|
||||
return nil, fmt.Errorf("create response: %w", err)
|
||||
}
|
||||
if created, err := s.responseRepo.GetByAccountAndID(ctx, accountID, resp.ID); err == nil {
|
||||
return created, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -308,5 +308,5 @@ func (s *CaptainAssistantResponseService) Delete(ctx context.Context, accountID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.responseRepo.Delete(ctx, resp.ID)
|
||||
return s.responseRepo.DeleteByAccount(ctx, accountID, resp.ID)
|
||||
}
|
||||
|
||||
@@ -4,20 +4,22 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
||||
)
|
||||
|
||||
// BulkActionType defines the type of bulk AI operation.
|
||||
type BulkActionType string
|
||||
|
||||
const (
|
||||
BulkActionLabelSuggestion BulkActionType = "label_suggestion"
|
||||
BulkActionReplySuggestion BulkActionType = "reply_suggestion"
|
||||
BulkActionFollowUp BulkActionType = "follow_up"
|
||||
BulkActionLabelSuggestion BulkActionType = "label_suggestion"
|
||||
BulkActionReplySuggestion BulkActionType = "reply_suggestion"
|
||||
BulkActionFollowUp BulkActionType = "follow_up"
|
||||
)
|
||||
|
||||
// CaptainBulkActionService handles batch AI operations on multiple conversations.
|
||||
@@ -30,6 +32,8 @@ type CaptainBulkActionService struct {
|
||||
llmProvider llm.Provider
|
||||
taskExtendedSvc *CaptainTaskExtendedService
|
||||
responseSvc *CaptainAssistantResponseService
|
||||
responseRepo *repository.CaptainAssistantResponseRepo
|
||||
documentRepo *repository.CaptainDocumentRepo
|
||||
}
|
||||
|
||||
func NewCaptainBulkActionService(
|
||||
@@ -52,6 +56,11 @@ func NewCaptainBulkActionService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainBulkActionService) SetCaptainResourceRepos(responseRepo *repository.CaptainAssistantResponseRepo, documentRepo *repository.CaptainDocumentRepo) {
|
||||
s.responseRepo = responseRepo
|
||||
s.documentRepo = documentRepo
|
||||
}
|
||||
|
||||
// --- Request/Response DTOs ---
|
||||
|
||||
// BulkActionRequest is the input for a bulk AI operation.
|
||||
@@ -65,12 +74,27 @@ type BulkActionRequest struct {
|
||||
|
||||
// BulkActionResult holds the results of a bulk operation.
|
||||
type BulkActionResult struct {
|
||||
Action BulkActionType `json:"action"`
|
||||
Action BulkActionType `json:"action"`
|
||||
Processed int `json:"processed"`
|
||||
Failed int `json:"failed"`
|
||||
Results []json.RawMessage `json:"results"`
|
||||
}
|
||||
|
||||
type ChatwootBulkActionRequest struct {
|
||||
Type string `json:"type"`
|
||||
IDs []uint `json:"ids"`
|
||||
Fields struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
type ChatwootBulkActionResult struct {
|
||||
AssistantResponses []model.CaptainAssistantResponse
|
||||
IDs []uint
|
||||
Count int64
|
||||
Empty bool
|
||||
}
|
||||
|
||||
// --- Business logic ---
|
||||
|
||||
// Execute performs a bulk AI action on multiple conversations.
|
||||
@@ -102,6 +126,80 @@ func (s *CaptainBulkActionService) Execute(ctx context.Context, accountID uint,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainBulkActionService) ExecuteChatwoot(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
|
||||
if req == nil || req.Type == "" || len(req.IDs) == 0 || req.Fields.Status == "" {
|
||||
return nil, fmt.Errorf("invalid bulk action request")
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case "AssistantResponse":
|
||||
return s.executeAssistantResponseBulk(ctx, accountID, req)
|
||||
case "AssistantDocument":
|
||||
return s.executeAssistantDocumentBulk(ctx, accountID, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported bulk action type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainBulkActionService) executeAssistantResponseBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
|
||||
if s.responseRepo == nil {
|
||||
return nil, fmt.Errorf("assistant response repository unavailable")
|
||||
}
|
||||
|
||||
db := s.responseRepo.DB().WithContext(ctx)
|
||||
switch req.Fields.Status {
|
||||
case "approve":
|
||||
if err := db.Model(&model.CaptainAssistantResponse{}).
|
||||
Where("account_id = ? AND id IN ? AND status = ?", accountID, req.IDs, model.ResponseStatusPending).
|
||||
Update("status", model.ResponseStatusApproved).Error; err != nil {
|
||||
return nil, fmt.Errorf("approve assistant responses: %w", err)
|
||||
}
|
||||
var responses []model.CaptainAssistantResponse
|
||||
if err := db.Preload("Assistant").Where("account_id = ? AND id IN ?", accountID, req.IDs).Find(&responses).Error; err != nil {
|
||||
return nil, fmt.Errorf("load assistant responses: %w", err)
|
||||
}
|
||||
return &ChatwootBulkActionResult{AssistantResponses: responses}, nil
|
||||
case "delete":
|
||||
if err := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainAssistantResponse{}).Error; err != nil {
|
||||
return nil, fmt.Errorf("delete assistant responses: %w", err)
|
||||
}
|
||||
return &ChatwootBulkActionResult{Empty: true}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported assistant response status: %s", req.Fields.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainBulkActionService) executeAssistantDocumentBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
|
||||
if s.documentRepo == nil {
|
||||
return nil, fmt.Errorf("document repository unavailable")
|
||||
}
|
||||
|
||||
db := s.documentRepo.DB().WithContext(ctx)
|
||||
switch req.Fields.Status {
|
||||
case "delete":
|
||||
result := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainDocument{})
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("delete documents: %w", result.Error)
|
||||
}
|
||||
return &ChatwootBulkActionResult{Count: result.RowsAffected}, nil
|
||||
case "sync":
|
||||
now := time.Now().Unix()
|
||||
result := db.Model(&model.CaptainDocument{}).
|
||||
Where("account_id = ? AND id IN ?", accountID, req.IDs).
|
||||
Updates(map[string]any{
|
||||
"sync_status": model.DocumentSyncStatusPending,
|
||||
"last_sync_attempted_at": &now,
|
||||
"last_sync_error_code": "",
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("sync documents: %w", result.Error)
|
||||
}
|
||||
return &ChatwootBulkActionResult{IDs: req.IDs, Count: result.RowsAffected}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported document status: %s", req.Fields.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainBulkActionService) bulkLabelSuggestion(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) {
|
||||
query := &LabelSuggestionQuery{
|
||||
ConversationIDs: req.ConversationIDs,
|
||||
|
||||
@@ -20,12 +20,23 @@ import (
|
||||
// CaptainCustomToolService implements business logic for CaptainCustomTool operations.
|
||||
// Reference: Chatwoot enterprise/app/controllers/api/v1/captain/custom_tools_controller.rb
|
||||
type CaptainCustomToolService struct {
|
||||
toolRepo *repository.CaptainCustomToolRepo
|
||||
toolRepo *repository.CaptainCustomToolRepo
|
||||
httpClient HTTPDoer
|
||||
}
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// NewCaptainCustomToolService creates a new CaptainCustomToolService.
|
||||
func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo) *CaptainCustomToolService {
|
||||
return &CaptainCustomToolService{toolRepo: toolRepo}
|
||||
return &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) {
|
||||
if client != nil {
|
||||
s.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request DTOs ---
|
||||
@@ -257,6 +268,11 @@ type ExecuteToolResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TestToolResult struct {
|
||||
Status int `json:"status"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// ExecuteTool calls the external HTTP endpoint configured in the custom tool.
|
||||
// Reference: Chatwoot Captain::CustomTool#execute
|
||||
func (s *CaptainCustomToolService) ExecuteTool(ctx context.Context, id uint, params map[string]interface{}) (*ExecuteToolResult, error) {
|
||||
@@ -298,8 +314,7 @@ func (s *CaptainCustomToolService) ExecuteTool(ctx context.Context, id uint, par
|
||||
}
|
||||
|
||||
// Execute HTTP call
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
resp, err := s.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("ExecuteTool HTTP call: %v", err)
|
||||
return &ExecuteToolResult{Success: false, Error: err.Error()}, nil
|
||||
@@ -437,29 +452,96 @@ func applyAuth(req *http.Request, tool *model.CaptainCustomTool) error {
|
||||
|
||||
// TestToolRequest is the DTO for testing a custom tool.
|
||||
type TestToolRequest struct {
|
||||
ToolID uint `json:"tool_id" validate:"required"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
ToolID uint `json:"tool_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EndpointURL string `json:"endpoint_url"`
|
||||
HTTPMethod string `json:"http_method"`
|
||||
AuthType string `json:"auth_type"`
|
||||
AuthConfig json.RawMessage `json:"auth_config"`
|
||||
ParamSchema json.RawMessage `json:"param_schema"`
|
||||
RequestTemplate string `json:"request_template"`
|
||||
ResponseTemplate string `json:"response_template"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
// TestTool tests a custom tool with given parameters without requiring it to be enabled.
|
||||
func (s *CaptainCustomToolService) TestTool(ctx context.Context, accountID uint, req *TestToolRequest) (*ExecuteToolResult, error) {
|
||||
tool, err := s.toolRepo.GetByID(ctx, req.ToolID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("custom tool not found: %w", err)
|
||||
func (s *CaptainCustomToolService) TestTool(ctx context.Context, accountID uint, req *TestToolRequest) (*TestToolResult, error) {
|
||||
tool := &model.CaptainCustomTool{
|
||||
AccountID: accountID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
EndpointURL: req.EndpointURL,
|
||||
HTTPMethod: req.HTTPMethod,
|
||||
AuthType: model.ToolAuthType(req.AuthType),
|
||||
AuthConfig: req.AuthConfig,
|
||||
ParamSchema: req.ParamSchema,
|
||||
RequestTemplate: req.RequestTemplate,
|
||||
ResponseTemplate: req.ResponseTemplate,
|
||||
Enabled: true,
|
||||
}
|
||||
if req.ToolID != 0 {
|
||||
stored, err := s.toolRepo.GetByID(ctx, req.ToolID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("custom tool not found: %w", err)
|
||||
}
|
||||
if stored.AccountID != accountID {
|
||||
return nil, fmt.Errorf("custom tool does not belong to this account")
|
||||
}
|
||||
tool = stored
|
||||
}
|
||||
if tool.EndpointURL == "" {
|
||||
return nil, fmt.Errorf("endpoint_url is required")
|
||||
}
|
||||
if tool.HTTPMethod == "" {
|
||||
tool.HTTPMethod = "GET"
|
||||
}
|
||||
if tool.AuthType == "" {
|
||||
tool.AuthType = model.ToolAuthTypeNone
|
||||
}
|
||||
|
||||
// Verify the tool belongs to the specified account
|
||||
if tool.AccountID != accountID {
|
||||
return nil, fmt.Errorf("custom tool does not belong to this account")
|
||||
}
|
||||
|
||||
// TestTool can execute even disabled tools (for validation before enabling)
|
||||
result, err := s.ExecuteTool(ctx, req.ToolID, req.Params)
|
||||
status, body, err := executeCustomToolHTTP(ctx, s.httpClient, tool, req.Params)
|
||||
if err != nil {
|
||||
return &ExecuteToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("test execution failed: %v", err),
|
||||
}, nil
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
if len(body) > 500 {
|
||||
body = body[:500]
|
||||
}
|
||||
return &TestToolResult{Status: status, Body: body}, nil
|
||||
}
|
||||
|
||||
func executeCustomToolHTTP(ctx context.Context, client HTTPDoer, tool *model.CaptainCustomTool, params map[string]interface{}) (int, string, error) {
|
||||
requestBody, err := buildRequestBody(tool.RequestTemplate, params)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("build request body: %w", err)
|
||||
}
|
||||
|
||||
method := strings.ToUpper(tool.HTTPMethod)
|
||||
var httpReq *http.Request
|
||||
if method == "GET" || method == "DELETE" {
|
||||
httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, nil)
|
||||
} else {
|
||||
httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, bytes.NewReader(requestBody))
|
||||
}
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("create HTTP request: %w", err)
|
||||
}
|
||||
if method != "GET" && method != "DELETE" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if err := applyAuth(httpReq, tool); err != nil {
|
||||
return 0, "", fmt.Errorf("apply auth: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
return resp.StatusCode, string(respBody), nil
|
||||
}
|
||||
|
||||
@@ -18,19 +18,25 @@ import (
|
||||
// CaptainDocumentService implements business logic for CaptainDocument operations.
|
||||
// Reference: Chatwoot enterprise/app/controllers/api/v1/captain/documents_controller.rb
|
||||
type CaptainDocumentService struct {
|
||||
documentRepo *repository.CaptainDocumentRepo
|
||||
llmProvider llm.Provider
|
||||
documentRepo *repository.CaptainDocumentRepo
|
||||
assistantRepo *repository.CaptainAssistantRepo
|
||||
llmProvider llm.Provider
|
||||
}
|
||||
|
||||
// NewCaptainDocumentService creates a new CaptainDocumentService.
|
||||
func NewCaptainDocumentService(
|
||||
documentRepo *repository.CaptainDocumentRepo,
|
||||
llmProvider llm.Provider,
|
||||
assistantRepo ...*repository.CaptainAssistantRepo,
|
||||
) *CaptainDocumentService {
|
||||
return &CaptainDocumentService{
|
||||
s := &CaptainDocumentService{
|
||||
documentRepo: documentRepo,
|
||||
llmProvider: llmProvider,
|
||||
}
|
||||
if len(assistantRepo) > 0 {
|
||||
s.assistantRepo = assistantRepo[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --- Request DTOs ---
|
||||
@@ -39,6 +45,7 @@ func NewCaptainDocumentService(
|
||||
type CreateDocumentRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
ExternalLink string `json:"external_link" validate:"required"`
|
||||
AssistantID uint `json:"assistant_id"`
|
||||
}
|
||||
|
||||
// UpdateDocumentRequest is the DTO for updating a document.
|
||||
@@ -47,22 +54,40 @@ type UpdateDocumentRequest struct {
|
||||
ExternalLink string `json:"external_link"`
|
||||
}
|
||||
|
||||
type ListDocumentsRequest struct {
|
||||
AssistantID uint
|
||||
Page int
|
||||
PerPage int
|
||||
Filter string
|
||||
Source string
|
||||
Sort string
|
||||
SearchKey string
|
||||
}
|
||||
|
||||
// --- CRUD Operations ---
|
||||
|
||||
// Create creates a new CaptainDocument.
|
||||
func (s *CaptainDocumentService) Create(ctx context.Context, assistantID, accountID uint, req *CreateDocumentRequest) (*model.CaptainDocument, error) {
|
||||
if s.assistantRepo != nil {
|
||||
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %w", err)
|
||||
}
|
||||
}
|
||||
doc := &model.CaptainDocument{
|
||||
AccountID: accountID,
|
||||
AssistantID: assistantID,
|
||||
Name: req.Name,
|
||||
AccountID: accountID,
|
||||
AssistantID: assistantID,
|
||||
Name: req.Name,
|
||||
ExternalLink: req.ExternalLink,
|
||||
Status: model.DocumentStatusPending,
|
||||
Status: model.DocumentStatusPending,
|
||||
}
|
||||
|
||||
if err := s.documentRepo.Create(ctx, doc); err != nil {
|
||||
applogger.L().Errorf("Create captain document: %v", err)
|
||||
return nil, fmt.Errorf("create document: %w", err)
|
||||
}
|
||||
if created, err := s.documentRepo.GetByAccountAndID(ctx, accountID, doc.ID); err == nil {
|
||||
return created, nil
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -76,6 +101,15 @@ func (s *CaptainDocumentService) Get(ctx context.Context, id uint) (*model.Capta
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *CaptainDocumentService) GetByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) {
|
||||
doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get captain document: %v", err)
|
||||
return nil, fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Update updates an existing document.
|
||||
func (s *CaptainDocumentService) Update(ctx context.Context, id uint, req *UpdateDocumentRequest) (*model.CaptainDocument, error) {
|
||||
doc, err := s.documentRepo.GetByID(ctx, id)
|
||||
@@ -106,6 +140,17 @@ func (s *CaptainDocumentService) Delete(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CaptainDocumentService) DeleteByAccount(ctx context.Context, accountID, id uint) error {
|
||||
if _, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id); err != nil {
|
||||
return fmt.Errorf("document not found: %w", err)
|
||||
}
|
||||
if err := s.documentRepo.DeleteByAccount(ctx, accountID, id); err != nil {
|
||||
applogger.L().Errorf("Delete captain document: %v", err)
|
||||
return fmt.Errorf("delete document: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves documents for an assistant with pagination.
|
||||
func (s *CaptainDocumentService) List(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainDocument, int64, error) {
|
||||
docs, count, err := s.documentRepo.ListByAssistant(ctx, assistantID, offset, limit)
|
||||
@@ -116,6 +161,46 @@ func (s *CaptainDocumentService) List(ctx context.Context, assistantID uint, off
|
||||
return docs, count, nil
|
||||
}
|
||||
|
||||
func (s *CaptainDocumentService) ListByAccount(ctx context.Context, accountID uint, req ListDocumentsRequest) ([]model.CaptainDocument, int64, int, error) {
|
||||
page := req.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
perPage := req.PerPage
|
||||
if perPage <= 0 {
|
||||
perPage = 25
|
||||
}
|
||||
docs, count, err := s.documentRepo.ListByAccount(ctx, accountID, repository.CaptainDocumentListFilters{
|
||||
AssistantID: req.AssistantID,
|
||||
Source: req.Source,
|
||||
Filter: req.Filter,
|
||||
SearchKey: req.SearchKey,
|
||||
Sort: req.Sort,
|
||||
Offset: (page - 1) * perPage,
|
||||
Limit: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List captain documents: %v", err)
|
||||
return nil, 0, page, fmt.Errorf("list documents: %w", err)
|
||||
}
|
||||
return docs, count, page, nil
|
||||
}
|
||||
|
||||
func (s *CaptainDocumentService) MarkSyncing(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) {
|
||||
doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("document not found: %w", err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
doc.LastSyncAttemptedAt = &now
|
||||
doc.SyncStatus = model.DocumentSyncStatusPending
|
||||
doc.LastSyncErrorCode = ""
|
||||
if err := s.documentRepo.Update(ctx, doc); err != nil {
|
||||
return nil, fmt.Errorf("mark document syncing: %w", err)
|
||||
}
|
||||
return s.documentRepo.GetByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
|
||||
// --- Document Processing ---
|
||||
|
||||
// ProcessDocument extracts content and generates embedding for a document.
|
||||
@@ -256,4 +341,4 @@ func computeFingerprint(content string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(content))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user