[H-337] Fix Captain provider runtime and knowledge flow (#61)

* fix(H-337): configure Captain provider runtime

* fix(captain): make knowledge rebuild atomic

* fix(captain): scope retrieval provider failures

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-20 14:46:01 +08:00
committed by GitHub
co-authored by rogee
parent 3d9817c9f5
commit 60ad320e8d
23 changed files with 713 additions and 112 deletions
@@ -1,8 +1,10 @@
package v1
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"strconv"
@@ -227,6 +229,19 @@ func (h *CaptainAssistantHandler) Drilldown(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func handleCaptainProviderError(c *gin.Context, err error) {
var networkErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkErr) && networkErr.Timeout()) {
response.AbortWithStatusError(c, http.StatusGatewayTimeout, response.ErrCopilotProviderTimeout, "Copilot provider request timed out")
return
}
if errors.As(err, &networkErr) {
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderUnreachable, "Copilot provider endpoint is unreachable")
return
}
handleServiceError(c, err)
}
// CreateMessageReport records feedback for a Captain-authored message.
func (h *CaptainAssistantHandler) CreateMessageReport(c *gin.Context) {
accountID := parseAccountIDParam(c)
@@ -450,7 +465,7 @@ func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found")
return
}
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate response")
handleCaptainProviderError(c, err)
return
}
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
@@ -213,6 +214,7 @@ func setupCaptainAssistantHandlerTestWithProvider(t *testing.T, provider llm.Pro
&model.Inbox{},
&model.CaptainAssistant{},
&model.CaptainInbox{},
&model.CaptainAssistantResponse{},
))
t.Cleanup(func() {
sqlDB, _ := db.DB()
@@ -358,7 +360,7 @@ func TestCaptainAssistantHandler_AccountScopedShowAndInboxBinding(t *testing.T)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback(t *testing.T) {
func TestCaptainAssistantHandler_PlaygroundProviderMissingFailsClosed(t *testing.T) {
router, db := setupCaptainAssistantHandlerTest(t)
account := seedCaptainAssistantAccount(t, db, "Captain Org")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{"model":"gpt-test"}`), Status: model.AssistantStatusActive}
@@ -372,18 +374,12 @@ func TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback(t *testing.T) {
},
}
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
assert.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotContains(t, payload, "data")
assert.Equal(t, "Captain assistant response generation is not configured for this account.", payload["content"])
assert.NotContains(t, payload, "response")
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "COPILOT_NOT_CONFIGURED")
}
func TestCaptainAssistantHandler_PlaygroundDefaultsHistoryAndScopesAccount(t *testing.T) {
router, db := setupCaptainAssistantHandlerTest(t)
router, db := setupCaptainAssistantHandlerTestWithProvider(t, &captainPlaygroundFakeProvider{content: "Assistant response"})
account := seedCaptainAssistantAccount(t, db, "Account One")
otherAccount := seedCaptainAssistantAccount(t, db, "Account Two")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
@@ -434,8 +430,8 @@ func TestCaptainAssistantHandler_PlaygroundV2AppendsCurrentMessageOnce(t *testin
assert.Equal(t, "Hello assistant", provider.lastRequest.Messages[1].Content)
}
func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallback(t *testing.T) {
provider := &captainPlaygroundFakeProvider{err: errors.New("provider unavailable")}
func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorFailsClosed(t *testing.T) {
provider := &captainPlaygroundFakeProvider{err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}}
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
account := seedCaptainAssistantAccount(t, db, "Captain Org")
account.FeatureFlags = `{"captain_integration_v2":true}`
@@ -445,23 +441,83 @@ func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallbac
body := map[string]any{"message_content": "Hello assistant"}
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
}
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(t, "conversation_handoff", payload["response"])
assert.Equal(t, false, payload["handoff_tool_called"])
assert.Contains(t, payload["reasoning"], "Error occurred: llm generation failed: provider unavailable")
assert.NotContains(t, payload, "content")
assert.NotContains(t, payload, "success")
assert.NotContains(t, payload, "data")
func TestCaptainAssistantHandler_PlaygroundEmbeddingFailureFailsClosed(t *testing.T) {
provider := &captainPlaygroundFakeProvider{embeddingErr: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}}
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
account := seedCaptainAssistantAccount(t, db, "Captain Org")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
require.NoError(t, db.Create(assistant).Error)
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
assert.Zero(t, provider.calls)
}
func TestCaptainAssistantHandler_PlaygroundEmptyEmbeddingFailsClosed(t *testing.T) {
for name, embeddingResponse := range map[string]*llm.EmbeddingResponse{
"nil response": nil,
"empty data": {},
"empty first vector": {Data: []llm.EmbeddingData{{}}},
} {
t.Run(name, func(t *testing.T) {
provider := &captainPlaygroundFakeProvider{embeddingResponse: embeddingResponse, embeddingResponseSet: true}
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
account := seedCaptainAssistantAccount(t, db, "Captain Org")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
require.NoError(t, db.Create(assistant).Error)
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
assert.Zero(t, provider.calls)
assert.Equal(t, 1, provider.embeddingCalls)
})
}
}
func TestCaptainAssistantHandler_PlaygroundFAQStoreFailureFailsClosed(t *testing.T) {
provider := &captainPlaygroundFakeProvider{embeddingResponse: &llm.EmbeddingResponse{Data: []llm.EmbeddingData{{Embedding: []float64{0.1, 0.2, 0.3}}}}}
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
account := seedCaptainAssistantAccount(t, db, "Captain Org")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
require.NoError(t, db.Create(assistant).Error)
require.NoError(t, db.Create(&model.CaptainAssistantResponse{
AccountID: account.ID, AssistantID: assistant.ID, Question: "FAQ", Answer: "Answer", Status: model.ResponseStatusApproved,
}).Error)
require.NoError(t, db.Migrator().DropTable(&model.CaptainAssistantResponse{}))
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
assert.Zero(t, provider.calls)
}
func TestCaptainAssistantHandler_PlaygroundDisabledAssistantFailsClosed(t *testing.T) {
router, db := setupCaptainAssistantHandlerTestWithProvider(t, &captainPlaygroundFakeProvider{content: "must not run"})
account := seedCaptainAssistantAccount(t, db, "Captain Org")
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Status: model.AssistantStatusArchived}
require.NoError(t, db.Create(assistant).Error)
body := map[string]any{"message_content": "Hello assistant"}
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "CAPTAIN_ASSISTANT_DISABLED")
}
type captainPlaygroundFakeProvider struct {
content string
err error
calls int
lastRequest llm.ChatRequest
content string
err error
embeddingResponse *llm.EmbeddingResponse
embeddingResponseSet bool
embeddingErr error
embeddingCalls int
calls int
lastRequest llm.ChatRequest
}
func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
@@ -474,6 +530,10 @@ func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req
}
func (p *captainPlaygroundFakeProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
p.embeddingCalls++
if p.embeddingErr != nil || p.embeddingResponseSet || p.embeddingResponse != nil {
return p.embeddingResponse, p.embeddingErr
}
return &llm.EmbeddingResponse{}, nil
}
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
@@ -1160,6 +1159,14 @@ func handleServiceError(c *gin.Context, err error) {
return
}
errMsg := err.Error()
if errors.Is(err, service.ErrCaptainAssistantDisabled) {
response.AbortWithStatusError(c, http.StatusConflict, response.ErrCaptainAssistantDisabled, errMsg)
return
}
if errors.Is(err, service.ErrCaptainKnowledgeRetrieval) {
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderUnreachable, "Captain knowledge retrieval failed")
return
}
if errors.Is(err, llm.ErrProviderNotConfigured) {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrCopilotNotConfigured, errMsg)
return
@@ -1178,11 +1185,6 @@ func handleServiceError(c *gin.Context, err error) {
}
return
}
var networkErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkErr) && networkErr.Timeout()) {
response.AbortWithStatusError(c, http.StatusGatewayTimeout, response.ErrCopilotProviderTimeout, "Copilot provider request timed out")
return
}
lower := strings.ToLower(errMsg)
if strings.Contains(lower, "not found") || strings.Contains(lower, "record not found") {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, errMsg)
@@ -4,7 +4,9 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
@@ -935,6 +937,21 @@ func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidConversationID(
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func TestNonCopilotNetworkErrorKeepsInternalErrorContract(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/non-copilot-error", func(c *gin.Context) {
handleServiceError(c, &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")})
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/non-copilot-error", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
assert.Contains(t, recorder.Body.String(), "INTERNAL_ERROR")
assert.NotContains(t, recorder.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
}
// Run the test suite
func TestConversationHandlerTestSuite(t *testing.T) {
suite.Run(t, new(ConversationHandlerTestSuite))