feat(copilot): finish configuration center
This commit is contained in:
@@ -152,10 +152,10 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error)
|
||||
}
|
||||
now := time.Now()
|
||||
admin := &model.User{}
|
||||
if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "User", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil {
|
||||
if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "SuperAdmin", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil {
|
||||
return nil, fmt.Errorf("seed admin user: %w", err)
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "User", "active": true, "available": true, "confirmed_at": now}).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "SuperAdmin", "active": true, "available": true, "confirmed_at": now}).Error; err != nil {
|
||||
return nil, fmt.Errorf("update admin user: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -814,41 +814,43 @@ func Bootstrap(env string) (*App, error) {
|
||||
contactMergeRepo := repository.NewContactMergeRepo(db)
|
||||
contactMergeService := service.NewContactMergeService(contactMergeRepo, db)
|
||||
handlers := &router.Handlers{
|
||||
Auth: v1.NewAuthHandler(authService, oauthService, profileService),
|
||||
MFA: v1.NewMFAHandler(mfaService),
|
||||
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker),
|
||||
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
|
||||
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
|
||||
WebWidget: v1.NewWebWidgetHandler(inboxService),
|
||||
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
|
||||
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
|
||||
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
|
||||
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
|
||||
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
|
||||
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
|
||||
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
|
||||
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
|
||||
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
|
||||
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
|
||||
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
|
||||
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
|
||||
EmailWebhook: emailWebhookHandler,
|
||||
Message: v1.NewMessageHandler(messageService),
|
||||
Profile: v1.NewProfileHandler(profileService, uploadService),
|
||||
Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher),
|
||||
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
|
||||
Team: v1.NewTeamHandler(teamService),
|
||||
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
|
||||
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
|
||||
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
|
||||
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
|
||||
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
|
||||
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
|
||||
CopilotConfig: v1.NewCopilotConfigHandler(copilotConfigService, captainPreferenceService),
|
||||
Auth: v1.NewAuthHandler(authService, oauthService, profileService),
|
||||
MFA: v1.NewMFAHandler(mfaService),
|
||||
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker),
|
||||
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
|
||||
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
|
||||
WebWidget: v1.NewWebWidgetHandler(inboxService),
|
||||
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
|
||||
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
|
||||
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
|
||||
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
|
||||
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
|
||||
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
|
||||
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
|
||||
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
|
||||
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
|
||||
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
|
||||
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
|
||||
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
|
||||
EmailWebhook: emailWebhookHandler,
|
||||
Message: v1.NewMessageHandler(messageService),
|
||||
Profile: v1.NewProfileHandler(profileService, uploadService),
|
||||
Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher),
|
||||
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
|
||||
Team: v1.NewTeamHandler(teamService),
|
||||
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
|
||||
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
|
||||
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
|
||||
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
|
||||
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
|
||||
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
|
||||
CopilotConfig: v1.NewCopilotConfigHandler(copilotConfigService, captainPreferenceService).
|
||||
WithAuditService(auditService).
|
||||
WithArticleService(articleService),
|
||||
CaptainTaskExtended: v1.NewCaptainTaskExtendedHandler(captainTaskExtendedService),
|
||||
CaptainAssistantResponse: v1.NewCaptainAssistantResponseHandler(captainAssistantResponseService),
|
||||
CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService),
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -17,9 +18,10 @@ import (
|
||||
// Claims represents JWT token claims.
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
AccountID uint `json:"account_id"` // current active account
|
||||
Role string `json:"role"` // agent/administrator/custom_role
|
||||
Provider string `json:"provider"` // email/google/saml
|
||||
AccountID uint `json:"account_id"` // current active account
|
||||
Role string `json:"role"` // agent/administrator/custom_role
|
||||
UserType string `json:"user_type,omitempty"` // user/super_admin platform identity
|
||||
Provider string `json:"provider"` // email/google/saml
|
||||
CustomRoleID uint `json:"custom_role_id,omitempty"` // enterprise custom role
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -45,14 +47,26 @@ func NewJWTService(cfg *config.JWTConfig) *JWTService {
|
||||
// Access Token: 15min expiry with full Claims
|
||||
// Refresh Token: 7 days expiry, only UserID + Provider
|
||||
func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role string) (*TokenPair, error) {
|
||||
userType := "user"
|
||||
typeValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(user.Type), "_", ""))
|
||||
if user.Role == "super_admin" || role == "super_admin" || typeValue == "superadmin" {
|
||||
userType = "super_admin"
|
||||
}
|
||||
|
||||
// Access Token
|
||||
accessExpiry := time.Now().Add(time.Duration(s.cfg.ExpiryHours) * time.Hour)
|
||||
accessClaims := &Claims{
|
||||
UserID: user.ID,
|
||||
AccountID: accountID,
|
||||
Role: role,
|
||||
Provider: user.Provider,
|
||||
CustomRoleID: func() uint { if user.CustomRoleID != nil { return *user.CustomRoleID }; return 0 }(),
|
||||
UserID: user.ID,
|
||||
AccountID: accountID,
|
||||
Role: role,
|
||||
UserType: userType,
|
||||
Provider: user.Provider,
|
||||
CustomRoleID: func() uint {
|
||||
if user.CustomRoleID != nil {
|
||||
return *user.CustomRoleID
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiry),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -161,4 +175,4 @@ func (s *JWTService) RefreshAccessToken(refreshTokenString string, accountID uin
|
||||
}
|
||||
|
||||
return s.GenerateTokenPair(user, accountID, role)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,28 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
|
||||
assertChatwootAuthUserFixture(t, data)
|
||||
}
|
||||
|
||||
func TestChatwootAuthValidateTokenSerializesPlatformAdminType(t *testing.T) {
|
||||
router, db, user := setupChatwootAuthTest(t)
|
||||
require.NoError(t, db.Model(user).Updates(map[string]any{
|
||||
"role": "super_admin",
|
||||
"type": "User",
|
||||
}).Error)
|
||||
|
||||
token := signInAndReturnAccessToken(t, router)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/auth/validate_token", nil)
|
||||
req.Header.Set("access-token", token)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
data := body["payload"].(map[string]any)["data"].(map[string]any)
|
||||
require.Equal(t, "SuperAdmin", data["type"])
|
||||
require.Equal(t, "administrator", data["role"])
|
||||
}
|
||||
|
||||
func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
|
||||
router, _, _ := setupChatwootAuthTest(t)
|
||||
token := signInAndReturnAccessToken(t, router)
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *AutoReplyRuleHandlerTestSuite) SetupSuite() {
|
||||
s.router = r
|
||||
|
||||
// Register routes matching the handler's expected URL patterns
|
||||
accountsGroup := r.Group("/api/v1/accounts/:id/captain")
|
||||
accountsGroup := r.Group("/api/v1/accounts/:account_id/captain")
|
||||
{
|
||||
accountsGroup.POST("/assistants/:assistant_id/auto_reply_rules", s.handler.Create)
|
||||
accountsGroup.GET("/auto_reply_rules/:rule_id", s.handler.Get)
|
||||
@@ -493,11 +493,8 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
|
||||
}
|
||||
s.Require().NoError(s.db.Create(rule).Error)
|
||||
|
||||
// Note: The handler sets accountID from path param but assigns to _ (not used in evalCtx).
|
||||
// AutoReplyEvaluationContext.AccountID has no json tag, so it stays 0 from JSON binding.
|
||||
// The service EvaluateRules will use AccountID=0 for FindActiveByInbox.
|
||||
// This means even with a valid account in the path, the evaluate endpoint currently
|
||||
// searches by account_id=0. We test the handler behavior as-is.
|
||||
// The account path parameter is authoritative and is copied into the evaluation
|
||||
// context before the service searches active rules.
|
||||
body := `{"message_content": "This is an urgent matter", "sender_type": "contact", "conversation_status": "open"}`
|
||||
w := s.doRequest(http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/accounts/%d/captain/auto_reply_rules/evaluate", account.ID),
|
||||
@@ -511,8 +508,7 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
|
||||
|
||||
dataMap, ok := resp.Data.(map[string]interface{})
|
||||
s.Require().True(ok)
|
||||
// ShouldReply will be false because AccountID=0 in evalCtx doesn't match our rule's account_id
|
||||
assert.Equal(s.T(), false, dataMap["should_reply"])
|
||||
assert.Equal(s.T(), true, dataMap["should_reply"])
|
||||
}
|
||||
|
||||
func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_InvalidJSON() {
|
||||
@@ -555,4 +551,4 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_NoActiveRules() {
|
||||
|
||||
func TestAutoReplyRuleHandlerTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AutoReplyRuleHandlerTestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +175,29 @@ func TestCaptainPreferencesInvalidAccountID(t *testing.T) {
|
||||
w := f.request(http.MethodGet, "/api/v1/accounts/abc/captain/preferences", nil)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
func TestCaptainPreferencesUpdateAndClearBehavior(t *testing.T) {
|
||||
f := newCaptainPreferenceFixture(t)
|
||||
|
||||
w := f.request(http.MethodPut, f.path(""), map[string]any{
|
||||
"behavior": map[string]any{
|
||||
"tone": "friendly",
|
||||
"language": "auto",
|
||||
"max_response_length": 750,
|
||||
"custom_prompt_suffix": "Use short steps",
|
||||
"auto_label_enabled": true,
|
||||
"auto_follow_up_enabled": true,
|
||||
"auto_reply_enabled": false,
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
behavior := decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
|
||||
require.Equal(t, "Use short steps", behavior["custom_prompt_suffix"])
|
||||
|
||||
w = f.request(http.MethodPut, f.path(""), map[string]any{
|
||||
"behavior": map[string]any{"custom_prompt_suffix": ""},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
behavior = decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
|
||||
require.Equal(t, "", behavior["custom_prompt_suffix"])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -16,12 +19,24 @@ import (
|
||||
type CopilotConfigHandler struct {
|
||||
platform *service.CopilotConfigService
|
||||
account *service.CaptainPreferenceService
|
||||
audit *service.AuditService
|
||||
articles *service.ArticleService
|
||||
}
|
||||
|
||||
func NewCopilotConfigHandler(platform *service.CopilotConfigService, account *service.CaptainPreferenceService) *CopilotConfigHandler {
|
||||
return &CopilotConfigHandler{platform: platform, account: account}
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) WithAuditService(audit *service.AuditService) *CopilotConfigHandler {
|
||||
h.audit = audit
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) WithArticleService(articles *service.ArticleService) *CopilotConfigHandler {
|
||||
h.articles = articles
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformGet(c *gin.Context) {
|
||||
payload, err := h.platform.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -42,9 +57,62 @@ func (h *CopilotConfigHandler) PlatformUpdate(c *gin.Context) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.recordPlatformUpdate(c, input, payload)
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) recordPlatformUpdate(c *gin.Context, input service.CopilotProviderConfigInput, payload *service.CopilotProviderConfigPayload) {
|
||||
if h.audit == nil || payload == nil {
|
||||
return
|
||||
}
|
||||
changes := map[string]any{
|
||||
"chat": map[string]any{
|
||||
"provider": payload.Chat.Provider,
|
||||
"base_url": payload.Chat.BaseURL,
|
||||
"model": payload.Chat.Model,
|
||||
"api_key_configured": payload.Chat.APIKey.Configured,
|
||||
"api_key_changed": input.Chat.APIKey != "" || input.Chat.ClearAPIKey,
|
||||
},
|
||||
"embedding": map[string]any{
|
||||
"mode": payload.Embedding.Mode,
|
||||
"provider": payload.Embedding.Provider,
|
||||
"base_url": payload.Embedding.BaseURL,
|
||||
"model": payload.Embedding.Model,
|
||||
"dimensions": payload.Embedding.Dimensions,
|
||||
"api_key_configured": payload.Embedding.APIKey.Configured,
|
||||
"api_key_changed": input.Embedding.APIKey != "" || input.Embedding.ClearAPIKey,
|
||||
},
|
||||
"generation": payload.Generation,
|
||||
"request": payload.Request,
|
||||
"configured": payload.Configured,
|
||||
}
|
||||
raw, err := json.Marshal(changes)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
audit := &model.Audit{
|
||||
AuditableType: "InstallationConfig",
|
||||
AuditableID: 1,
|
||||
Action: "update",
|
||||
AuditedChanges: raw,
|
||||
RemoteAddress: c.ClientIP(),
|
||||
RequestUUID: firstNonEmpty(c.GetHeader("X-Request-ID"), c.GetHeader("X-Correlation-ID")),
|
||||
Comment: "Copilot provider configuration updated",
|
||||
}
|
||||
if accountID := c.GetUint("account_id"); accountID != 0 {
|
||||
audit.AccountID = &accountID
|
||||
audit.AssociatedType = "Account"
|
||||
audit.AssociatedID = &accountID
|
||||
}
|
||||
if userID := getUserID(c); userID != 0 {
|
||||
audit.UserID = &userID
|
||||
audit.UserType = "SuperAdmin"
|
||||
}
|
||||
if _, err := h.audit.CreateAudit(c.Request.Context(), audit); err != nil {
|
||||
applogger.L().Warnf("Copilot provider audit skipped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
|
||||
var input service.CopilotProviderConfigInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -55,7 +123,8 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
|
||||
if err != nil {
|
||||
status := http.StatusUnprocessableEntity
|
||||
if errors.Is(err, llm.ErrProviderNotConfigured) {
|
||||
status = http.StatusConflict
|
||||
response.AbortWithStatusError(c, http.StatusConflict, response.ErrCopilotNotConfigured, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -63,6 +132,31 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStatus(c *gin.Context) {
|
||||
if h.articles == nil {
|
||||
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.articles.EmbeddingReindexStatus())
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStart(c *gin.Context) {
|
||||
if h.articles == nil {
|
||||
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
|
||||
return
|
||||
}
|
||||
status, err := h.articles.StartEmbeddingReindex()
|
||||
if err != nil {
|
||||
code := http.StatusUnprocessableEntity
|
||||
if status.Running {
|
||||
code = http.StatusConflict
|
||||
}
|
||||
c.JSON(code, gin.H{"error": err.Error(), "status": status})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) AccountGet(c *gin.Context) {
|
||||
if !captainPreferencesCanUpdate(c) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "administrator role required")
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type copilotConfigHandlerFixture struct {
|
||||
db *gorm.DB
|
||||
router *gin.Engine
|
||||
account *model.Account
|
||||
}
|
||||
|
||||
func newCopilotConfigHandlerFixture(t *testing.T) *copilotConfigHandlerFixture {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{}, &model.Audit{}))
|
||||
account := &model.Account{Name: "Copilot Config", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
manager := llm.NewProviderManager()
|
||||
platformService := service.NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
|
||||
preferenceService := service.NewCaptainPreferenceService(repository.NewCaptainPreferenceRepo(db), repository.NewAccountRepo(db))
|
||||
preferenceService.SetCopilotConfigService(platformService)
|
||||
handler := NewCopilotConfigHandler(platformService, preferenceService).
|
||||
WithAuditService(service.NewAuditService(repository.NewAuditRepo(db)))
|
||||
|
||||
router := gin.New()
|
||||
superAdmin := router.Group("/platform/api/v1/copilot", func(c *gin.Context) {
|
||||
c.Set("user_type", "super_admin")
|
||||
c.Set("account_id", account.ID)
|
||||
c.Next()
|
||||
}, middleware.SuperAdmin())
|
||||
superAdmin.GET("/config", handler.PlatformGet)
|
||||
superAdmin.PUT("/config", handler.PlatformUpdate)
|
||||
superAdmin.POST("/config/test", handler.PlatformTest)
|
||||
|
||||
accountAdmin := router.Group("/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
|
||||
c.Set("role", "administrator")
|
||||
c.Next()
|
||||
})
|
||||
accountAdmin.GET("", handler.AccountGet)
|
||||
accountAdmin.PUT("", handler.AccountUpdate)
|
||||
|
||||
router.PUT("/forbidden/platform/api/v1/copilot/config", func(c *gin.Context) {
|
||||
c.Set("user_type", "user")
|
||||
c.Next()
|
||||
}, middleware.SuperAdmin(), handler.PlatformUpdate)
|
||||
router.GET("/forbidden/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
|
||||
c.Set("role", "agent")
|
||||
c.Next()
|
||||
}, handler.AccountGet)
|
||||
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
require.NoError(t, dbErr)
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
return &copilotConfigHandlerFixture{db: db, router: router, account: account}
|
||||
}
|
||||
|
||||
func (f *copilotConfigHandlerFixture) request(method, path string, body any) *httptest.ResponseRecorder {
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
raw, _ = json.Marshal(body)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(method, path, bytes.NewReader(raw))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
f.router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func copilotConfigRequest(baseURL, apiKey string) map[string]any {
|
||||
return map[string]any{
|
||||
"chat": map[string]any{
|
||||
"provider": "openai_compatible",
|
||||
"base_url": baseURL,
|
||||
"model": "chat-model",
|
||||
"api_key": apiKey,
|
||||
},
|
||||
"embedding": map[string]any{
|
||||
"mode": "reuse_chat_credentials",
|
||||
"provider": "openai_compatible",
|
||||
"base_url": baseURL,
|
||||
"model": "embedding-model",
|
||||
"dimensions": 3,
|
||||
},
|
||||
"generation": map[string]any{"temperature": 0.2, "max_tokens": 512},
|
||||
"request": map[string]any{"timeout_seconds": 10, "max_retries": 0},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing.T) {
|
||||
f := newCopilotConfigHandlerFixture(t)
|
||||
input := copilotConfigRequest("https://llm.example.com/v1", "plain-secret-key")
|
||||
|
||||
forbidden := f.request(http.MethodPut, "/forbidden/platform/api/v1/copilot/config", input)
|
||||
require.Equal(t, http.StatusForbidden, forbidden.Code, forbidden.Body.String())
|
||||
|
||||
updated := f.request(http.MethodPut, "/platform/api/v1/copilot/config", input)
|
||||
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
|
||||
require.NotContains(t, updated.Body.String(), "plain-secret-key")
|
||||
require.Contains(t, updated.Body.String(), "pla****-key")
|
||||
|
||||
var stored model.InstallationConfig
|
||||
require.NoError(t, f.db.Where("name = ?", "COPILOT_CHAT_API_KEY").First(&stored).Error)
|
||||
require.Equal(t, "plain-secret-key", stored.Value)
|
||||
var audit model.Audit
|
||||
require.NoError(t, f.db.Where("auditable_type = ?", "InstallationConfig").First(&audit).Error)
|
||||
require.NotContains(t, string(audit.AuditedChanges), "plain-secret-key")
|
||||
require.NotContains(t, string(audit.AuditedChanges), "pla****-key")
|
||||
require.Contains(t, string(audit.AuditedChanges), `"api_key_changed":true`)
|
||||
require.NotNil(t, audit.AccountID)
|
||||
require.Equal(t, f.account.ID, *audit.AccountID)
|
||||
require.Equal(t, "Account", audit.AssociatedType)
|
||||
|
||||
accountPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(f.account.ID), 10) + "/copilot/config"
|
||||
accountPayload := f.request(http.MethodGet, accountPath, nil)
|
||||
require.Equal(t, http.StatusOK, accountPayload.Code, accountPayload.Body.String())
|
||||
require.NotContains(t, accountPayload.Body.String(), "plain-secret-key")
|
||||
require.NotContains(t, accountPayload.Body.String(), "pla****-key")
|
||||
require.Contains(t, accountPayload.Body.String(), `"configured":true`)
|
||||
|
||||
agentPayload := f.request(http.MethodGet, "/forbidden"+accountPath, nil)
|
||||
require.Equal(t, http.StatusForbidden, agentPayload.Code, agentPayload.Body.String())
|
||||
}
|
||||
|
||||
func TestCopilotConfigHandlerTestEndpointDoesNotPersistCandidate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/chat/completions":
|
||||
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
|
||||
case "/embeddings":
|
||||
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embedding-model"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
f := newCopilotConfigHandlerFixture(t)
|
||||
tested := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", copilotConfigRequest(server.URL, "candidate-key"))
|
||||
require.Equal(t, http.StatusOK, tested.Code, tested.Body.String())
|
||||
require.Contains(t, tested.Body.String(), `"ok":true`)
|
||||
require.NotContains(t, tested.Body.String(), "candidate-key")
|
||||
|
||||
current := f.request(http.MethodGet, "/platform/api/v1/copilot/config", nil)
|
||||
require.Equal(t, http.StatusOK, current.Code, current.Body.String())
|
||||
require.Contains(t, current.Body.String(), `"configured":false`)
|
||||
}
|
||||
|
||||
func TestCopilotConfigHandlerUsesStandardNotConfiguredError(t *testing.T) {
|
||||
f := newCopilotConfigHandlerFixture(t)
|
||||
response := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", map[string]any{})
|
||||
require.Equal(t, http.StatusConflict, response.Code, response.Body.String())
|
||||
require.Contains(t, response.Body.String(), "COPILOT_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
func TestCopilotProviderErrorsAreSanitized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/provider-error", func(c *gin.Context) {
|
||||
handleServiceError(c, fmt.Errorf("provider failed: %w", &llm.APIError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Message: "invalid key sk-secret-value",
|
||||
}))
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/provider-error", nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "COPILOT_PROVIDER_AUTHENTICATION_FAILED")
|
||||
require.NotContains(t, recorder.Body.String(), "sk-secret-value")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.llmProvider == nil {
|
||||
writeSSEMessage(c, "error", `{"error": "Captain is disabled", "status": 422, "done": true}`)
|
||||
writeSSEMessage(c, "error", `{"code": "COPILOT_NOT_CONFIGURED", "error": "Copilot provider is not configured", "status": 503, "done": true}`)
|
||||
writeSSEMessage(c, "done", `{"done": true}`)
|
||||
return
|
||||
}
|
||||
@@ -98,14 +98,15 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
|
||||
|
||||
// Stream from LLM using callback pattern
|
||||
streamReq := llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: chatMessages,
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 1024,
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
err = h.llmProvider.ChatCompletionStream(c.Request.Context(), streamReq, func(chunk llm.StreamChunk) error {
|
||||
streamCtx := llm.WithAccountFeature(c.Request.Context(), uint(accountID), "copilot")
|
||||
err = h.llmProvider.ChatCompletionStream(streamCtx, streamReq, func(chunk llm.StreamChunk) error {
|
||||
if len(chunk.Choices) > 0 {
|
||||
content := chunk.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
|
||||
@@ -245,8 +245,8 @@ func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req ChatRe
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody))
|
||||
_, _ = io.Copy(io.Discard, httpResp.Body)
|
||||
return &APIError{StatusCode: httpResp.StatusCode, Message: "Anthropic provider request failed"}
|
||||
}
|
||||
|
||||
// Parse Anthropic SSE format
|
||||
@@ -306,7 +306,7 @@ func (p *AnthropicProvider) doRequestWithRetry(ctx context.Context, path string,
|
||||
for attempt := 0; attempt <= p.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
applogger.L().Infof("Anthropic retry (attempt %d/%d) after %v: %v", attempt, p.maxRetries, backoff, lastErr)
|
||||
applogger.L().Infof("Anthropic retry (attempt %d/%d) after %v", attempt, p.maxRetries, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
@@ -345,7 +345,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, path string, body []b
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Message: "Anthropic provider request failed"}
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
@@ -123,8 +123,9 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req ChatReque
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
applogger.L().Errorf("ChatCompletionStream: unexpected status %d: %s", httpResp.StatusCode, string(respBody))
|
||||
return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody))
|
||||
apiErr := parseAPIError(httpResp.StatusCode, respBody)
|
||||
applogger.L().Errorf("ChatCompletionStream: provider returned status %d", httpResp.StatusCode)
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return p.parseSSEStream(httpResp.Body, onChunk)
|
||||
@@ -164,7 +165,7 @@ func (p *OpenAIProvider) doRequestWithRetry(ctx context.Context, path string, bo
|
||||
if attempt > 0 {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
applogger.L().Infof("Retrying request (attempt %d/%d) after %v: %v", attempt, p.maxRetries, backoff, lastErr)
|
||||
applogger.L().Infof("Retrying provider request (attempt %d/%d) after %v", attempt, p.maxRetries, backoff)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -210,7 +211,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, path string, body []byte
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
apiErr := parseAPIError(resp.StatusCode, respBody)
|
||||
applogger.L().Errorf("API error (status %d): %v", resp.StatusCode, apiErr)
|
||||
applogger.L().Errorf("Provider API error (status %d, type=%s, code=%s)", resp.StatusCode, apiErr.Type, apiErr.Code)
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
|
||||
@@ -32,21 +32,21 @@ type StreamingProvider interface {
|
||||
|
||||
// ChatRequest represents a request to the chat completion API.
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Tools []ToolDefinition `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Tools []ToolDefinition `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage represents a single message in a chat conversation.
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant message: tool calls initiated by the model
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // tool role message: ID of the tool call this responds to
|
||||
Name string `json:"name,omitempty"` // tool role message: name of the tool
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant message: tool calls initiated by the model
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // tool role message: ID of the tool call this responds to
|
||||
Name string `json:"name,omitempty"` // tool role message: name of the tool
|
||||
}
|
||||
|
||||
// ToolCall represents a tool call requested by the LLM.
|
||||
@@ -101,23 +101,24 @@ type TokenUsage struct {
|
||||
|
||||
// EmbeddingRequest represents a request to the embeddings API.
|
||||
type EmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
// EmbeddingResponse represents the response from an embeddings API.
|
||||
type EmbeddingResponse struct {
|
||||
Object string `json:"object"`
|
||||
Object string `json:"object"`
|
||||
Data []EmbeddingData `json:"data"`
|
||||
Model string `json:"model"`
|
||||
Usage TokenUsage `json:"usage"`
|
||||
Model string `json:"model"`
|
||||
Usage TokenUsage `json:"usage"`
|
||||
}
|
||||
|
||||
// EmbeddingData represents a single embedding result.
|
||||
type EmbeddingData struct {
|
||||
Object string `json:"object"`
|
||||
Index int `json:"index"`
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Object string `json:"object"`
|
||||
Index int `json:"index"`
|
||||
Embedding []float64 `json:"embedding"`
|
||||
}
|
||||
|
||||
// StreamChunk represents a single chunk in a streaming response.
|
||||
@@ -131,13 +132,13 @@ type StreamChunk struct {
|
||||
|
||||
// StreamChoice represents a single choice in a streaming chunk.
|
||||
type StreamChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta StreamDelta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Index int `json:"index"`
|
||||
Delta StreamDelta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// StreamDelta represents the delta content in a streaming chunk.
|
||||
type StreamDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ func (m *ProviderManager) CreateEmbedding(ctx context.Context, req EmbeddingRequ
|
||||
return nil, err
|
||||
}
|
||||
req.Model = snapshot.config.EmbeddingModel
|
||||
req.Dimensions = snapshot.config.EmbeddingDimensions
|
||||
return snapshot.embedding.CreateEmbedding(ctx, req)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -17,6 +18,123 @@ func TestProviderManagerRequiresPageConfiguration(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrProviderNotConfigured)
|
||||
}
|
||||
|
||||
func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.T) {
|
||||
var request ChatRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewProviderManager()
|
||||
manager.SetAccountModelResolver(func(_ context.Context, accountID uint, feature string) (string, error) {
|
||||
assert.Equal(t, uint(42), accountID)
|
||||
assert.Equal(t, "editor", feature)
|
||||
return "account-editor-model", nil
|
||||
})
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "platform-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
Temperature: 0.25,
|
||||
MaxTokens: 777,
|
||||
}))
|
||||
|
||||
ctx := WithAccountFeature(context.Background(), 42, "editor")
|
||||
_, err := manager.ChatCompletion(ctx, ChatRequest{Model: "hard-coded-model"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "account-editor-model", request.Model)
|
||||
assert.Equal(t, 0.25, request.Temperature)
|
||||
assert.Equal(t, 777, request.MaxTokens)
|
||||
}
|
||||
|
||||
func TestProviderManagerUsesSeparateEmbeddingProvider(t *testing.T) {
|
||||
var chatCalls atomic.Int32
|
||||
chatServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
chatCalls.Add(1)
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer chatServer.Close()
|
||||
|
||||
var embeddingRequest EmbeddingRequest
|
||||
var authorization string
|
||||
embeddingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authorization = r.Header.Get("Authorization")
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&embeddingRequest))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embed-model"}`))
|
||||
}))
|
||||
defer embeddingServer.Close()
|
||||
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatBaseURL: chatServer.URL,
|
||||
ChatAPIKey: "chat-key",
|
||||
ChatModel: "chat-model",
|
||||
EmbeddingMode: EmbeddingModeSeparate,
|
||||
EmbeddingProvider: "openai_compatible",
|
||||
EmbeddingBaseURL: embeddingServer.URL,
|
||||
EmbeddingAPIKey: "embedding-key",
|
||||
EmbeddingModel: "embed-model",
|
||||
EmbeddingDimensions: 3,
|
||||
}))
|
||||
|
||||
_, err := manager.CreateEmbedding(context.Background(), EmbeddingRequest{Model: "ignored", Input: []string{"hello"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(0), chatCalls.Load())
|
||||
assert.Equal(t, "Bearer embedding-key", authorization)
|
||||
assert.Equal(t, "embed-model", embeddingRequest.Model)
|
||||
assert.Equal(t, 3, embeddingRequest.Dimensions)
|
||||
}
|
||||
|
||||
func TestProviderManagerExplicitZeroRetriesDoesNotRetry(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "temporary failure", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "test-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
MaxRetries: 0,
|
||||
}))
|
||||
|
||||
_, err := manager.ChatCompletion(context.Background(), ChatRequest{})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
}
|
||||
|
||||
func TestProviderManagerFailedConfigurationKeepsWorkingSnapshot(t *testing.T) {
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai",
|
||||
ChatAPIKey: "working-key",
|
||||
ChatModel: "working-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
}))
|
||||
|
||||
err := manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "anthropic",
|
||||
ChatAPIKey: "bad-key",
|
||||
ChatModel: "bad-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
})
|
||||
require.Error(t, err)
|
||||
snapshot, configured := manager.Snapshot()
|
||||
require.True(t, configured)
|
||||
assert.Equal(t, "working-model", snapshot.ChatModel)
|
||||
}
|
||||
|
||||
func TestProviderManagerClearRemovesActiveProvider(t *testing.T) {
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
|
||||
@@ -53,6 +53,7 @@ func AuthMiddlewareWithService(jwtSvc *auth.JWTService) gin.HandlerFunc {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("account_id", claims.AccountID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("user_type", claims.UserType)
|
||||
c.Set("provider", claims.Provider)
|
||||
c.Set("custom_role_id", claims.CustomRoleID)
|
||||
c.Set("claims", claims) // full Claims struct for handlers that need it
|
||||
|
||||
@@ -118,6 +118,33 @@ func TestAuthMiddleware_ChatwootAccessTokenHeader(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_AllowsPlatformAdminThroughSuperAdminGuard(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := makeJWTConfig()
|
||||
jwtService := auth.NewJWTService(cfg)
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: 1},
|
||||
Provider: "email",
|
||||
Role: "super_admin",
|
||||
Type: "User",
|
||||
}
|
||||
pair, err := jwtService.GenerateTokenPair(user, 2, "administrator")
|
||||
assert.NoError(t, err)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(cfg), SuperAdmin())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("access-token", pair.AccessToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := makeJWTConfig()
|
||||
|
||||
@@ -6,6 +6,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -17,30 +18,17 @@ import (
|
||||
// Super admins are platform-level administrators that can manage all accounts,
|
||||
// platform apps, and system configuration.
|
||||
//
|
||||
// This checks the user's type field (not the account-level role).
|
||||
// user.type = "super_admin" is set at the User model level, not AccountUser.
|
||||
// This checks the signed platform user type (not the account-level role).
|
||||
//
|
||||
// Usage:
|
||||
// router.GET("/platform/accounts", SuperAdmin(), listAllAccounts)
|
||||
// router.POST("/platform/apps", SuperAdmin(), createPlatformApp)
|
||||
// router.GET("/platform/analytics", SuperAdmin(), viewPlatformAnalytics)
|
||||
//
|
||||
// router.GET("/platform/accounts", SuperAdmin(), listAllAccounts)
|
||||
// router.POST("/platform/apps", SuperAdmin(), createPlatformApp)
|
||||
// router.GET("/platform/analytics", SuperAdmin(), viewPlatformAnalytics)
|
||||
func SuperAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check for super_admin flag in context (set by AuthRequired middleware)
|
||||
userType, exists := c.Get("user_type")
|
||||
if !exists {
|
||||
// No user_type in context — check claims for super_admin indication
|
||||
_, claimsExists := c.Get("auth_claims")
|
||||
if claimsExists {
|
||||
// Claims exist but user_type not set — not super_admin
|
||||
}
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
|
||||
"Super admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
typeStr, ok := userType.(string)
|
||||
if !ok || typeStr != "super_admin" {
|
||||
if !exists || !isSuperAdminType(userType) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
|
||||
"Super admin access required. Your account does not have platform administration privileges.")
|
||||
return
|
||||
@@ -57,14 +45,14 @@ func SuperAdmin() gin.HandlerFunc {
|
||||
// Useful for endpoints that should be accessible to account admins and platform admins.
|
||||
//
|
||||
// Usage:
|
||||
// router.DELETE("/accounts/:id", SuperAdminOrAdministrator(), deleteAccount)
|
||||
//
|
||||
// router.DELETE("/accounts/:id", SuperAdminOrAdministrator(), deleteAccount)
|
||||
func SuperAdminOrAdministrator() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check super_admin first
|
||||
userType, exists := c.Get("user_type")
|
||||
if exists {
|
||||
typeStr, ok := userType.(string)
|
||||
if ok && typeStr == "super_admin" {
|
||||
if isSuperAdminType(userType) {
|
||||
c.Set("is_super_admin", true)
|
||||
c.Next()
|
||||
return
|
||||
@@ -94,4 +82,13 @@ func SuperAdminOrAdministrator() gin.HandlerFunc {
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isSuperAdminType(value any) bool {
|
||||
typeStr, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(typeStr), "_", ""))
|
||||
return normalized == "superadmin"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,19 @@ func TestSuperAdmin_IsSuperAdmin(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestSuperAdmin_AcceptsChatwootSerializedType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set("user_type", "SuperAdmin"); c.Next() })
|
||||
r.Use(SuperAdmin())
|
||||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestSuperAdmin_ClaimsExistButNoType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -58,4 +71,4 @@ func TestSuperAdmin_ClaimsExistButNoType(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, 403, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
type ArticleEmbedding struct {
|
||||
Base
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
Embedding json.RawMessage `gorm:"type:jsonb" json:"embedding"` // original JSONB storage (backward compat)
|
||||
VectorEmbedding pgvector.Vector `gorm:"type:vector(1536)" json:"-"` // pgvector column for cosine similarity search
|
||||
Embedding json.RawMessage `gorm:"type:jsonb" json:"embedding"` // original JSONB storage (backward compat)
|
||||
VectorEmbedding pgvector.Vector `gorm:"type:vector" json:"-"` // dimension follows the configured embedding model
|
||||
Term string `gorm:"type:text;not null" json:"term"` // searchable text content
|
||||
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"article,omitempty"`
|
||||
}
|
||||
|
||||
func (ArticleEmbedding) TableName() string { return "article_embeddings" }
|
||||
func (ArticleEmbedding) TableName() string { return "article_embeddings" }
|
||||
|
||||
@@ -25,6 +25,9 @@ func (r *ArticleEmbeddingRepo) Upsert(ctx context.Context, emb *model.ArticleEmb
|
||||
var existing model.ArticleEmbedding
|
||||
err := r.db.WithContext(ctx).Where("article_id = ?", emb.ArticleID).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
|
||||
return r.db.WithContext(ctx).Omit("VectorEmbedding").Create(emb).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(emb).Error
|
||||
}
|
||||
if err != nil {
|
||||
@@ -32,7 +35,11 @@ func (r *ArticleEmbeddingRepo) Upsert(ctx context.Context, emb *model.ArticleEmb
|
||||
}
|
||||
// Update existing
|
||||
existing.Embedding = emb.Embedding
|
||||
existing.VectorEmbedding = emb.VectorEmbedding
|
||||
existing.Term = emb.Term
|
||||
if r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
|
||||
return r.db.WithContext(ctx).Omit("VectorEmbedding").Save(&existing).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(&existing).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -2050,6 +2050,8 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
copilot.GET("/config", h.CopilotConfig.PlatformGet)
|
||||
copilot.PUT("/config", h.CopilotConfig.PlatformUpdate)
|
||||
copilot.POST("/config/test", h.CopilotConfig.PlatformTest)
|
||||
copilot.GET("/embeddings/reindex", h.CopilotConfig.PlatformEmbeddingReindexStatus)
|
||||
copilot.POST("/embeddings/reindex", h.CopilotConfig.PlatformEmbeddingReindexStart)
|
||||
}
|
||||
|
||||
// PlatformApp CRUD
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
@@ -30,6 +31,18 @@ type ArticleService struct {
|
||||
searchIndexer SearchIndexer
|
||||
worker *worker.WorkerPool
|
||||
translator ArticleTranslationBackend
|
||||
reindexMu sync.RWMutex
|
||||
reindexStatus EmbeddingReindexStatus
|
||||
}
|
||||
|
||||
type EmbeddingReindexStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
Processed int `json:"processed"`
|
||||
Failed int `json:"failed"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ArticleTranslationBackend interface {
|
||||
@@ -94,6 +107,55 @@ func (s *ArticleService) SetLLMProvider(provider llm.Provider) {
|
||||
s.llmProvider = provider
|
||||
}
|
||||
|
||||
func (s *ArticleService) EmbeddingReindexStatus() EmbeddingReindexStatus {
|
||||
s.reindexMu.RLock()
|
||||
defer s.reindexMu.RUnlock()
|
||||
return s.reindexStatus
|
||||
}
|
||||
|
||||
func (s *ArticleService) StartEmbeddingReindex() (EmbeddingReindexStatus, error) {
|
||||
if s.embeddingRepo == nil || s.llmProvider == nil {
|
||||
return EmbeddingReindexStatus{}, fmt.Errorf("embedding reindex is not configured")
|
||||
}
|
||||
s.reindexMu.Lock()
|
||||
if s.reindexStatus.Running {
|
||||
status := s.reindexStatus
|
||||
s.reindexMu.Unlock()
|
||||
return status, fmt.Errorf("embedding reindex is already running")
|
||||
}
|
||||
var articleIDs []uint
|
||||
if err := s.repo.DB().Model(&model.Article{}).Order("id ASC").Pluck("id", &articleIDs).Error; err != nil {
|
||||
s.reindexMu.Unlock()
|
||||
return EmbeddingReindexStatus{}, fmt.Errorf("list articles for embedding reindex: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.reindexStatus = EmbeddingReindexStatus{Running: true, Total: len(articleIDs), StartedAt: &now}
|
||||
status := s.reindexStatus
|
||||
s.reindexMu.Unlock()
|
||||
|
||||
go s.runEmbeddingReindex(articleIDs)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) runEmbeddingReindex(articleIDs []uint) {
|
||||
ctx := context.Background()
|
||||
for _, articleID := range articleIDs {
|
||||
err := s.GenerateEmbedding(ctx, articleID)
|
||||
s.reindexMu.Lock()
|
||||
s.reindexStatus.Processed++
|
||||
if err != nil {
|
||||
s.reindexStatus.Failed++
|
||||
s.reindexStatus.Error = err.Error()
|
||||
}
|
||||
s.reindexMu.Unlock()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.reindexMu.Lock()
|
||||
s.reindexStatus.Running = false
|
||||
s.reindexStatus.CompletedAt = &now
|
||||
s.reindexMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) {
|
||||
if s.searchIndexer != nil {
|
||||
logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article))
|
||||
@@ -893,7 +955,7 @@ func (s *ArticleService) SemanticSearch(ctx context.Context, portalID uint, quer
|
||||
|
||||
// Generate embedding for the query
|
||||
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Model: "text-embedding-3-small",
|
||||
Model: "",
|
||||
Input: []string{query},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -946,7 +1008,7 @@ func (s *ArticleService) GenerateEmbedding(ctx context.Context, articleID uint)
|
||||
|
||||
// Generate embedding
|
||||
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Model: "text-embedding-3-small",
|
||||
Model: "",
|
||||
Input: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -967,6 +1029,7 @@ func (s *ArticleService) GenerateEmbedding(ctx context.Context, articleID uint)
|
||||
VectorEmbedding: pgvector.NewVector(float32Emb),
|
||||
Term: text,
|
||||
}
|
||||
emb.Embedding, _ = json.Marshal(embedResp.Data[0].Embedding)
|
||||
|
||||
return s.embeddingRepo.Upsert(ctx, emb)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -24,6 +25,20 @@ type recordingArticleLLM struct {
|
||||
requests []llm.ChatRequest
|
||||
}
|
||||
|
||||
type embeddingArticleLLM struct{}
|
||||
|
||||
func (embeddingArticleLLM) ChatCompletion(context.Context, llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
return nil, fmt.Errorf("not used")
|
||||
}
|
||||
|
||||
func (embeddingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
||||
return &llm.EmbeddingResponse{Data: []llm.EmbeddingData{{Embedding: []float64{0.1, 0.2, 0.3}}}}, nil
|
||||
}
|
||||
|
||||
func (embeddingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error {
|
||||
return fmt.Errorf("not used")
|
||||
}
|
||||
|
||||
func (m *recordingArticleLLM) ChatCompletion(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
m.requests = append(m.requests, req)
|
||||
return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Content: " Texte traduit "}}}}, nil
|
||||
@@ -48,6 +63,32 @@ func setupArticleService(t *testing.T) (*gorm.DB, *repository.ArticleRepo, *Arti
|
||||
return db, repo, svc
|
||||
}
|
||||
|
||||
func TestArticleServiceEmbeddingReindexTracksProgress(t *testing.T) {
|
||||
db, _, svc := setupArticleService(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.ArticleEmbedding{}))
|
||||
account := createTestAccount(t, db)
|
||||
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-one" })
|
||||
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-two" })
|
||||
svc.SetEmbeddingRepo(repository.NewArticleEmbeddingRepo(db))
|
||||
svc.SetLLMProvider(embeddingArticleLLM{})
|
||||
|
||||
status, err := svc.StartEmbeddingReindex()
|
||||
require.NoError(t, err)
|
||||
require.True(t, status.Running)
|
||||
require.Equal(t, 2, status.Total)
|
||||
require.Eventually(t, func() bool {
|
||||
return !svc.EmbeddingReindexStatus().Running
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
status = svc.EmbeddingReindexStatus()
|
||||
require.Equal(t, 2, status.Processed)
|
||||
require.Equal(t, 0, status.Failed, status.Error)
|
||||
require.NotNil(t, status.CompletedAt)
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.ArticleEmbedding{}).Count(&count).Error)
|
||||
require.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
// ========== BulkActions ==========
|
||||
|
||||
func TestArticleService_BulkActions_Publish(t *testing.T) {
|
||||
|
||||
@@ -25,49 +25,49 @@ import (
|
||||
// --- CRUD DTOs ---
|
||||
|
||||
type CreateAutoReplyRuleRequest struct {
|
||||
AssistantID uint `json:"assistant_id" validate:"required"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText string `json:"response_text,omitempty"`
|
||||
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
|
||||
DelaySeconds int `json:"delay_seconds,omitempty"`
|
||||
OneTimeOnly *bool `json:"one_time_only,omitempty"`
|
||||
AssistantID uint `json:"assistant_id" validate:"required"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText string `json:"response_text,omitempty"`
|
||||
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
|
||||
DelaySeconds int `json:"delay_seconds,omitempty"`
|
||||
OneTimeOnly *bool `json:"one_time_only,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateAutoReplyRuleRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status *model.AutoReplyRuleStatus `json:"status,omitempty"`
|
||||
Mode *model.AutoReplyRuleMode `json:"mode,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText *string `json:"response_text,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status *model.AutoReplyRuleStatus `json:"status,omitempty"`
|
||||
Mode *model.AutoReplyRuleMode `json:"mode,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText *string `json:"response_text,omitempty"`
|
||||
LLMPromptOverride *string `json:"llm_prompt_override,omitempty"`
|
||||
DelaySeconds *int `json:"delay_seconds,omitempty"`
|
||||
OneTimeOnly *bool `json:"one_time_only,omitempty"`
|
||||
DelaySeconds *int `json:"delay_seconds,omitempty"`
|
||||
OneTimeOnly *bool `json:"one_time_only,omitempty"`
|
||||
}
|
||||
|
||||
type AutoReplyRuleResult struct {
|
||||
ID uint `json:"id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
AssistantID uint `json:"assistant_id"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status model.AutoReplyRuleStatus `json:"status"`
|
||||
Mode model.AutoReplyRuleMode `json:"mode"`
|
||||
Priority int `json:"priority"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText string `json:"response_text,omitempty"`
|
||||
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
|
||||
DelaySeconds int `json:"delay_seconds"`
|
||||
OneTimeOnly bool `json:"one_time_only"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
AssistantID uint `json:"assistant_id"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status model.AutoReplyRuleStatus `json:"status"`
|
||||
Mode model.AutoReplyRuleMode `json:"mode"`
|
||||
Priority int `json:"priority"`
|
||||
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
|
||||
ResponseText string `json:"response_text,omitempty"`
|
||||
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
|
||||
DelaySeconds int `json:"delay_seconds"`
|
||||
OneTimeOnly bool `json:"one_time_only"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// --- Auto-Reply Execution DTOs ---
|
||||
@@ -84,19 +84,19 @@ type AutoReplyEvaluationContext struct {
|
||||
}
|
||||
|
||||
type AutoReplyMatchResult struct {
|
||||
Rule *model.CaptainAutoReplyRule `json:"rule"`
|
||||
ReplyContent string `json:"reply_content"`
|
||||
ReplyMode model.AutoReplyRuleMode `json:"reply_mode"`
|
||||
ShouldReply bool `json:"should_reply"`
|
||||
Rule *model.CaptainAutoReplyRule `json:"rule"`
|
||||
ReplyContent string `json:"reply_content"`
|
||||
ReplyMode model.AutoReplyRuleMode `json:"reply_mode"`
|
||||
ShouldReply bool `json:"should_reply"`
|
||||
}
|
||||
|
||||
// AutoReplyRuleService provides CRUD + evaluation + execution for auto-reply rules.
|
||||
type AutoReplyRuleService struct {
|
||||
ruleRepo *repository.CaptainAutoReplyRuleRepo
|
||||
assistantRepo *repository.CaptainAssistantRepo
|
||||
ruleRepo *repository.CaptainAutoReplyRuleRepo
|
||||
assistantRepo *repository.CaptainAssistantRepo
|
||||
conversationRepo *repository.ConversationRepo
|
||||
llmProvider llm.Provider
|
||||
promptBuilder *SystemPromptBuilder
|
||||
llmProvider llm.Provider
|
||||
promptBuilder *SystemPromptBuilder
|
||||
}
|
||||
|
||||
// NewAutoReplyRuleService creates a new AutoReplyRuleService.
|
||||
@@ -415,6 +415,7 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
|
||||
}
|
||||
|
||||
cfg, _ := assistant.GetConfig()
|
||||
ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant")
|
||||
|
||||
// Build system prompt
|
||||
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
|
||||
@@ -433,16 +434,13 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
|
||||
}
|
||||
|
||||
modelName := cfg.Model
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4"
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: modelName,
|
||||
Model: modelName,
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: contextBuilder.String()},
|
||||
@@ -483,4 +481,4 @@ func ruleToResult(rule *model.CaptainAutoReplyRule) *AutoReplyRuleResult {
|
||||
CreatedAt: rule.CreatedAt,
|
||||
UpdatedAt: rule.UpdatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type ProcessResponseResult struct {
|
||||
|
||||
// ProcessResponse generates an AI-powered response for a conversation and optionally stores it as a message.
|
||||
func (s *CaptainAssistantResponseService) ProcessResponse(ctx context.Context, accountID uint, req *ProcessResponseRequest) (*ProcessResponseResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
// Validate request
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
|
||||
@@ -125,6 +125,7 @@ func (s *CaptainConversationService) collectConversationMessages(ctx context.Con
|
||||
}
|
||||
|
||||
func (s *CaptainConversationService) generateConversationResponse(ctx context.Context, accountID uint, conversation *model.Conversation, assistant *model.CaptainAssistant, history []CaptainConversationMessage) (*CaptainConversationResponse, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
if s.backend != nil {
|
||||
return s.backend.GenerateCaptainConversationResponse(ctx, CaptainConversationResponseRequest{AccountID: accountID, Conversation: conversation, Assistant: assistant, Messages: history})
|
||||
}
|
||||
@@ -152,9 +153,6 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
|
||||
}
|
||||
|
||||
modelName := cfg.Model
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4"
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
|
||||
@@ -622,7 +622,7 @@ func (s *CaptainDocumentService) generateResponseEmbedding(ctx context.Context,
|
||||
if s.llmProvider == nil {
|
||||
return pgvector.Vector{}, fmt.Errorf("embedding generation disabled")
|
||||
}
|
||||
result, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{Model: "text-embedding-3-small", Input: []string{content}})
|
||||
result, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{Model: "", Input: []string{content}})
|
||||
if err != nil {
|
||||
return pgvector.Vector{}, fmt.Errorf("generate response embedding: %w", err)
|
||||
}
|
||||
@@ -707,7 +707,7 @@ func (s *CaptainDocumentService) ProcessDocument(ctx context.Context, id uint) e
|
||||
// Generate embedding for the document content
|
||||
_, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Input: []string{content},
|
||||
Model: "text-embedding-ada-002",
|
||||
Model: "",
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("ProcessDocument embedding: %v", err)
|
||||
@@ -766,7 +766,7 @@ func (s *CaptainDocumentService) SyncDocument(ctx context.Context, id uint) erro
|
||||
// Re-generate embedding for updated content
|
||||
_, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Input: []string{content},
|
||||
Model: "text-embedding-ada-002",
|
||||
Model: "",
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("SyncDocument embedding: %v", err)
|
||||
|
||||
@@ -51,14 +51,14 @@ type CreatePreferenceRequest struct {
|
||||
// UpdatePreferenceRequest is the DTO for updating a preference.
|
||||
// Reference: Chatwoot enterprise/app/models/captain/preference.rb
|
||||
type UpdatePreferenceRequest struct {
|
||||
Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"`
|
||||
Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"`
|
||||
ResponseGuidelines string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"`
|
||||
AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"`
|
||||
AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"`
|
||||
AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"`
|
||||
MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"`
|
||||
CustomPromptSuffix string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"`
|
||||
Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"`
|
||||
Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"`
|
||||
ResponseGuidelines *string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"`
|
||||
AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"`
|
||||
AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"`
|
||||
AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"`
|
||||
MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"`
|
||||
CustomPromptSuffix *string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"`
|
||||
}
|
||||
|
||||
// UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params.
|
||||
@@ -109,44 +109,13 @@ type CaptainFeatureModel struct {
|
||||
CreditMultiplier int `json:"credit_multiplier"`
|
||||
}
|
||||
|
||||
var captainProviders = map[string]map[string]string{
|
||||
"openai": {"display_name": "OpenAI"},
|
||||
"anthropic": {"display_name": "Anthropic"},
|
||||
"gemini": {"display_name": "Gemini"},
|
||||
}
|
||||
|
||||
var captainModels = map[string]CaptainModelConfig{
|
||||
"gpt-4.1": {Provider: "openai", DisplayName: "GPT-4.1", CreditMultiplier: 3},
|
||||
"gpt-4.1-mini": {Provider: "openai", DisplayName: "GPT-4.1 Mini", CreditMultiplier: 1},
|
||||
"gpt-4.1-nano": {Provider: "openai", DisplayName: "GPT-4.1 Nano", CreditMultiplier: 1},
|
||||
"gpt-5.1": {Provider: "openai", DisplayName: "GPT-5.1", CreditMultiplier: 2},
|
||||
"gpt-5-mini": {Provider: "openai", DisplayName: "GPT-5 Mini", CreditMultiplier: 1},
|
||||
"gpt-5-nano": {Provider: "openai", DisplayName: "GPT-5 Nano", CreditMultiplier: 1},
|
||||
"gpt-5.2": {Provider: "openai", DisplayName: "GPT-5.2", CreditMultiplier: 3},
|
||||
"claude-haiku-4.5": {Provider: "anthropic", DisplayName: "Claude Haiku 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 2},
|
||||
"claude-sonnet-4.5": {Provider: "anthropic", DisplayName: "Claude Sonnet 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 3},
|
||||
"gemini-3-flash": {Provider: "gemini", DisplayName: "Gemini 3 Flash", ComingSoon: boolPtr(true), CreditMultiplier: 1},
|
||||
"gemini-3-pro": {Provider: "gemini", DisplayName: "Gemini 3 Pro", ComingSoon: boolPtr(true), CreditMultiplier: 3},
|
||||
"whisper-1": {Provider: "openai", DisplayName: "Whisper", CreditMultiplier: 1},
|
||||
"text-embedding-3-small": {Provider: "openai", DisplayName: "Text Embedding 3 Small", CreditMultiplier: 1},
|
||||
}
|
||||
|
||||
var captainFeatureModels = map[string][]string{
|
||||
"editor": {"gpt-4.1-mini", "gpt-4.1-nano", "gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "gemini-3-flash", "gemini-3-pro"},
|
||||
"assistant": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"},
|
||||
"copilot": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"},
|
||||
"label_suggestion": {"gpt-4.1-nano", "gpt-4.1-mini", "gpt-5-mini", "gemini-3-flash", "claude-haiku-4.5"},
|
||||
"audio_transcription": {"whisper-1"},
|
||||
"help_center_search": {"text-embedding-3-small"},
|
||||
}
|
||||
|
||||
var captainFeatureDefaults = map[string]string{
|
||||
"editor": "gpt-4.1-mini",
|
||||
"assistant": "gpt-5.1",
|
||||
"copilot": "gpt-5.1",
|
||||
"label_suggestion": "gpt-4.1-nano",
|
||||
"audio_transcription": "whisper-1",
|
||||
"help_center_search": "text-embedding-3-small",
|
||||
var captainFeatures = map[string]struct{}{
|
||||
"editor": {},
|
||||
"assistant": {},
|
||||
"copilot": {},
|
||||
"label_suggestion": {},
|
||||
"help_center_search": {},
|
||||
"audio_transcription": {}, // legacy API compatibility; not exposed by the new page.
|
||||
}
|
||||
|
||||
var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "help_center_search"}
|
||||
@@ -246,9 +215,17 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u
|
||||
|
||||
func (s *CaptainPreferenceService) providerConfigPayload(ctx context.Context) (*CopilotProviderConfigPayload, error) {
|
||||
if s.copilotConfigService == nil {
|
||||
return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil), nil
|
||||
return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil, nil), nil
|
||||
}
|
||||
return s.copilotConfigService.Get(ctx)
|
||||
payload, err := s.copilotConfigService.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Account-scoped APIs expose only whether credentials exist. Masked values
|
||||
// are reserved for the SuperAdmin platform endpoint.
|
||||
payload.Chat.APIKey.Masked = ""
|
||||
payload.Embedding.APIKey.Masked = ""
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID uint) (*model.Account, error) {
|
||||
@@ -328,22 +305,10 @@ func captainConfigPayload(account *model.Account, providerConfig *CopilotProvide
|
||||
}
|
||||
|
||||
func isCaptainFeature(key string) bool {
|
||||
_, ok := captainFeatureModels[key]
|
||||
_, ok := captainFeatures[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func validCaptainModelFor(feature, modelName string) bool {
|
||||
if modelName == "" {
|
||||
return false
|
||||
}
|
||||
for _, allowed := range captainFeatureModels[feature] {
|
||||
if allowed == modelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonMapString(raw datatypes.JSON) map[string]string {
|
||||
result := map[string]string{}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
@@ -367,10 +332,6 @@ func marshalJSONMap(value any) datatypes.JSON {
|
||||
return datatypes.JSON(raw)
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func defaultCaptainPreference(accountID uint) *model.CaptainPreference {
|
||||
return &model.CaptainPreference{
|
||||
AccountID: accountID,
|
||||
@@ -403,8 +364,8 @@ func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context,
|
||||
if req.Language != "" {
|
||||
pref.Language = req.Language
|
||||
}
|
||||
if req.ResponseGuidelines != "" {
|
||||
pref.ResponseGuidelines = req.ResponseGuidelines
|
||||
if req.ResponseGuidelines != nil {
|
||||
pref.ResponseGuidelines = *req.ResponseGuidelines
|
||||
}
|
||||
if req.AutoLabelEnabled != nil {
|
||||
pref.AutoLabelEnabled = *req.AutoLabelEnabled
|
||||
@@ -418,8 +379,8 @@ func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context,
|
||||
if req.MaxResponseLength != nil {
|
||||
pref.MaxResponseLength = *req.MaxResponseLength
|
||||
}
|
||||
if req.CustomPromptSuffix != "" {
|
||||
pref.CustomPromptSuffix = req.CustomPromptSuffix
|
||||
if req.CustomPromptSuffix != nil {
|
||||
pref.CustomPromptSuffix = *req.CustomPromptSuffix
|
||||
}
|
||||
if create {
|
||||
if err := s.repo.Create(ctx, pref); err != nil {
|
||||
@@ -518,8 +479,8 @@ func (s *CaptainPreferenceService) Update(ctx context.Context, accountID uint, r
|
||||
if req.Language != "" {
|
||||
pref.Language = req.Language
|
||||
}
|
||||
if req.ResponseGuidelines != "" {
|
||||
pref.ResponseGuidelines = req.ResponseGuidelines
|
||||
if req.ResponseGuidelines != nil {
|
||||
pref.ResponseGuidelines = *req.ResponseGuidelines
|
||||
}
|
||||
if req.AutoLabelEnabled != nil {
|
||||
pref.AutoLabelEnabled = *req.AutoLabelEnabled
|
||||
@@ -533,8 +494,8 @@ func (s *CaptainPreferenceService) Update(ctx context.Context, accountID uint, r
|
||||
if req.MaxResponseLength != nil {
|
||||
pref.MaxResponseLength = *req.MaxResponseLength
|
||||
}
|
||||
if req.CustomPromptSuffix != "" {
|
||||
pref.CustomPromptSuffix = req.CustomPromptSuffix
|
||||
if req.CustomPromptSuffix != nil {
|
||||
pref.CustomPromptSuffix = *req.CustomPromptSuffix
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, pref); err != nil {
|
||||
|
||||
@@ -460,7 +460,7 @@ func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantI
|
||||
// Generate embedding for the query
|
||||
embResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Input: []string{query},
|
||||
Model: "text-embedding-ada-002",
|
||||
Model: "",
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create embedding: %w", err)
|
||||
|
||||
@@ -26,11 +26,11 @@ type ParticipantAnalysisRequest struct {
|
||||
|
||||
// ParticipantInfo holds analysis results for a single participant.
|
||||
type ParticipantInfo struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"` // "customer", "agent", "manager"
|
||||
Sentiment string `json:"sentiment"` // "positive", "neutral", "negative"
|
||||
Topics []string `json:"topics"` // main topics discussed
|
||||
Engagement float64 `json:"engagement"` // engagement score 0-1
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"` // "customer", "agent", "manager"
|
||||
Sentiment string `json:"sentiment"` // "positive", "neutral", "negative"
|
||||
Topics []string `json:"topics"` // main topics discussed
|
||||
Engagement float64 `json:"engagement"` // engagement score 0-1
|
||||
}
|
||||
|
||||
// ParticipantAnalysisResult holds the participant analysis result.
|
||||
@@ -50,7 +50,7 @@ type ActionItem struct {
|
||||
Owner string `json:"owner,omitempty"` // person responsible
|
||||
Deadline string `json:"deadline,omitempty"`
|
||||
Priority string `json:"priority"` // "high", "medium", "low"
|
||||
Status string `json:"status"` // "pending", "in_progress", "completed"
|
||||
Status string `json:"status"` // "pending", "in_progress", "completed"
|
||||
}
|
||||
|
||||
// ActionItemsResult holds the extracted action items.
|
||||
@@ -60,8 +60,8 @@ type ActionItemsResult struct {
|
||||
|
||||
// LabelSuggestionRequest is the input for suggesting labels/priority for a conversation.
|
||||
type LabelSuggestionRequest struct {
|
||||
ConversationID uint `json:"conversation_id" validate:"required"`
|
||||
AssistantID uint `json:"assistant_id,omitempty"` // optional: use assistant guidelines
|
||||
ConversationID uint `json:"conversation_id" validate:"required"`
|
||||
AssistantID uint `json:"assistant_id,omitempty"` // optional: use assistant guidelines
|
||||
}
|
||||
|
||||
// InsightLabelSuggestionResult holds suggested labels and priority.
|
||||
@@ -101,6 +101,7 @@ func NewConversationInsightService(
|
||||
|
||||
// AnalyzeParticipants analyzes the participants in a conversation.
|
||||
func (s *ConversationInsightService) AnalyzeParticipants(ctx context.Context, accountID uint, req *ParticipantAnalysisRequest) (*ParticipantAnalysisResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
// Fetch conversation messages
|
||||
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
||||
if err != nil {
|
||||
@@ -128,7 +129,7 @@ Return your analysis as JSON in this exact format:
|
||||
}`
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: contextStr},
|
||||
@@ -161,6 +162,7 @@ Return your analysis as JSON in this exact format:
|
||||
|
||||
// ExtractActionItems extracts action items from a conversation.
|
||||
func (s *ConversationInsightService) ExtractActionItems(ctx context.Context, accountID uint, req *ActionItemsRequest) (*ActionItemsResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
||||
@@ -185,7 +187,7 @@ Return as JSON array:
|
||||
}`
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: contextStr},
|
||||
@@ -217,6 +219,7 @@ Return as JSON array:
|
||||
|
||||
// SuggestLabels suggests labels and priority for a conversation.
|
||||
func (s *ConversationInsightService) SuggestLabels(ctx context.Context, accountID uint, req *LabelSuggestionRequest) (*LabelSuggestionResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "label_suggestion")
|
||||
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
||||
@@ -252,7 +255,7 @@ Return as JSON:
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: contextStr},
|
||||
@@ -301,4 +304,4 @@ func (s *ConversationInsightService) fetchConversationContext(ctx context.Contex
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,21 @@ type CopilotEmbeddingConfigInput struct {
|
||||
CopilotSecretInput
|
||||
}
|
||||
|
||||
type CopilotGenerationConfigInput struct {
|
||||
Temperature *float64 `json:"temperature"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type CopilotRequestConfigInput struct {
|
||||
TimeoutSeconds *int `json:"timeout_seconds"`
|
||||
MaxRetries *int `json:"max_retries"`
|
||||
}
|
||||
|
||||
type CopilotProviderConfigInput struct {
|
||||
Chat CopilotChatConfigInput `json:"chat"`
|
||||
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
|
||||
Generation CopilotGenerationSettings `json:"generation"`
|
||||
Request CopilotRequestSettings `json:"request"`
|
||||
Chat CopilotChatConfigInput `json:"chat"`
|
||||
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
|
||||
Generation CopilotGenerationConfigInput `json:"generation"`
|
||||
Request CopilotRequestConfigInput `json:"request"`
|
||||
}
|
||||
|
||||
type CopilotSecretPayload struct {
|
||||
@@ -126,6 +136,7 @@ type CopilotProviderConfigPayload struct {
|
||||
Generation CopilotGenerationSettings `json:"generation"`
|
||||
Request CopilotRequestSettings `json:"request"`
|
||||
Configured bool `json:"configured"`
|
||||
AppliedAt *time.Time `json:"applied_at,omitempty"`
|
||||
Health *CopilotProviderHealth `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
@@ -181,7 +192,11 @@ func (s *CopilotConfigService) Get(ctx context.Context) (*CopilotProviderConfigP
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, health), nil
|
||||
appliedAt, err := s.loadAppliedAt(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, appliedAt, health), nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderConfigPayload, error) {
|
||||
@@ -212,12 +227,14 @@ func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProvider
|
||||
|
||||
if !configured {
|
||||
s.manager.Clear()
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
|
||||
now := time.Now().UTC()
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, nil), nil
|
||||
}
|
||||
if err := s.manager.Configure(runtimeCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
|
||||
now := time.Now().UTC()
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, nil), nil
|
||||
}
|
||||
|
||||
// Test validates and calls Chat and Embedding with candidate settings without
|
||||
@@ -324,17 +341,17 @@ func (s *CopilotConfigService) mergedConfig(ctx context.Context, input CopilotPr
|
||||
embeddingKey = strings.TrimSpace(input.Embedding.APIKey)
|
||||
}
|
||||
|
||||
if input.Generation.MaxTokens != 0 {
|
||||
settings.Generation.MaxTokens = input.Generation.MaxTokens
|
||||
if input.Generation.MaxTokens != nil {
|
||||
settings.Generation.MaxTokens = *input.Generation.MaxTokens
|
||||
}
|
||||
if input.Generation.Temperature >= 0 {
|
||||
settings.Generation.Temperature = input.Generation.Temperature
|
||||
if input.Generation.Temperature != nil {
|
||||
settings.Generation.Temperature = *input.Generation.Temperature
|
||||
}
|
||||
if input.Request.TimeoutSeconds != 0 {
|
||||
settings.Request.TimeoutSeconds = input.Request.TimeoutSeconds
|
||||
if input.Request.TimeoutSeconds != nil {
|
||||
settings.Request.TimeoutSeconds = *input.Request.TimeoutSeconds
|
||||
}
|
||||
if input.Request.MaxRetries >= 0 {
|
||||
settings.Request.MaxRetries = input.Request.MaxRetries
|
||||
if input.Request.MaxRetries != nil {
|
||||
settings.Request.MaxRetries = *input.Request.MaxRetries
|
||||
}
|
||||
|
||||
settings = normalizeCopilotProviderSettings(settings)
|
||||
@@ -415,6 +432,18 @@ func (s *CopilotConfigService) loadMatchingHealth(ctx context.Context, settings
|
||||
return &health, nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadAppliedAt(ctx context.Context) (*time.Time, error) {
|
||||
record, err := s.repo.FindByName(ctx, copilotProviderConfigKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Copilot provider applied time: %w", err)
|
||||
}
|
||||
appliedAt := record.UpdatedAt.UTC()
|
||||
return &appliedAt, nil
|
||||
}
|
||||
|
||||
func normalizeCopilotProviderSettings(settings CopilotProviderSettings) CopilotProviderSettings {
|
||||
settings.Chat.Provider = strings.ToLower(strings.TrimSpace(settings.Chat.Provider))
|
||||
settings.Chat.BaseURL = strings.TrimRight(strings.TrimSpace(settings.Chat.BaseURL), "/")
|
||||
@@ -514,6 +543,9 @@ func validateCopilotURL(raw, kind string) error {
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("Copilot %s base URL must be a valid HTTP(S) URL", kind)
|
||||
}
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return fmt.Errorf("Copilot %s base URL must not contain credentials, query parameters, or fragments", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -546,7 +578,7 @@ func copilotConfigComplete(settings CopilotProviderSettings, chatKey, embeddingK
|
||||
return true
|
||||
}
|
||||
|
||||
func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embeddingKey string, health *CopilotProviderHealth) *CopilotProviderConfigPayload {
|
||||
func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embeddingKey string, appliedAt *time.Time, health *CopilotProviderHealth) *CopilotProviderConfigPayload {
|
||||
return &CopilotProviderConfigPayload{
|
||||
Chat: CopilotChatConfigPayload{
|
||||
Provider: settings.Chat.Provider,
|
||||
@@ -565,6 +597,7 @@ func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embedding
|
||||
Generation: settings.Generation,
|
||||
Request: settings.Request,
|
||||
Configured: copilotConfigComplete(settings, chatKey, embeddingKey),
|
||||
AppliedAt: appliedAt,
|
||||
Health: health,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ func setupCopilotConfigServiceTest(t *testing.T) (*CopilotConfigService, *gorm.D
|
||||
return svc, db, manager
|
||||
}
|
||||
|
||||
func float64Ptr(v float64) *float64 { return &v }
|
||||
|
||||
func testCopilotInput(baseURL string) CopilotProviderConfigInput {
|
||||
return CopilotProviderConfigInput{
|
||||
Chat: CopilotChatConfigInput{
|
||||
@@ -41,8 +43,8 @@ func testCopilotInput(baseURL string) CopilotProviderConfigInput {
|
||||
Model: "custom-embedding",
|
||||
Dimensions: 3,
|
||||
},
|
||||
Generation: CopilotGenerationSettings{Temperature: 0.4, MaxTokens: 800},
|
||||
Request: CopilotRequestSettings{TimeoutSeconds: 30, MaxRetries: 0},
|
||||
Generation: CopilotGenerationConfigInput{Temperature: float64Ptr(0.4), MaxTokens: intPtr(800)},
|
||||
Request: CopilotRequestConfigInput{TimeoutSeconds: intPtr(30), MaxRetries: intPtr(0)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +98,13 @@ func TestCopilotConfigServiceRejectsAnthropicWithoutSeparateEmbedding(t *testing
|
||||
require.ErrorContains(t, err, "separate embedding provider")
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceRejectsCredentialsInBaseURL(t *testing.T) {
|
||||
svc, _, _ := setupCopilotConfigServiceTest(t)
|
||||
input := testCopilotInput("https://user:secret@llm.example.com/v1")
|
||||
_, err := svc.Update(context.Background(), input)
|
||||
require.ErrorContains(t, err, "must not contain credentials")
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceTestsChatAndEmbeddingWithoutChangingSavedConfig(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -377,7 +377,7 @@ func (s *CopilotService) generateAssistantContent(ctx context.Context, accountID
|
||||
|
||||
// Call LLM for assistant response
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: messages,
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 1024,
|
||||
@@ -414,7 +414,7 @@ func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: messages,
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 512,
|
||||
@@ -456,7 +456,7 @@ func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID ui
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: messages,
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 256,
|
||||
|
||||
@@ -25,30 +25,30 @@ func NewIntentService(llmProvider llm.Provider) *IntentService {
|
||||
type IntentType string
|
||||
|
||||
const (
|
||||
IntentTypeQuestion IntentType = "question" // User asks a question
|
||||
IntentTypeComplaint IntentType = "complaint" // User expresses dissatisfaction
|
||||
IntentTypeRequest IntentType = "request" // User requests an action/feature
|
||||
IntentTypeFeedback IntentType = "feedback" // User provides feedback
|
||||
IntentTypeGreeting IntentType = "greeting" // User says hello/greetings
|
||||
IntentTypeUrgent IntentType = "urgent" // User expresses urgency
|
||||
IntentTypeCancellation IntentType = "cancellation" // User wants to cancel/stop
|
||||
IntentTypeBilling IntentType = "billing" // User has billing/payment issue
|
||||
IntentTypeTechnical IntentType = "technical" // User has technical/bug issue
|
||||
IntentTypeOther IntentType = "other" // Unclassified intent
|
||||
IntentTypeQuestion IntentType = "question" // User asks a question
|
||||
IntentTypeComplaint IntentType = "complaint" // User expresses dissatisfaction
|
||||
IntentTypeRequest IntentType = "request" // User requests an action/feature
|
||||
IntentTypeFeedback IntentType = "feedback" // User provides feedback
|
||||
IntentTypeGreeting IntentType = "greeting" // User says hello/greetings
|
||||
IntentTypeUrgent IntentType = "urgent" // User expresses urgency
|
||||
IntentTypeCancellation IntentType = "cancellation" // User wants to cancel/stop
|
||||
IntentTypeBilling IntentType = "billing" // User has billing/payment issue
|
||||
IntentTypeTechnical IntentType = "technical" // User has technical/bug issue
|
||||
IntentTypeOther IntentType = "other" // Unclassified intent
|
||||
)
|
||||
|
||||
// IntentResult holds the classification result.
|
||||
type IntentResult struct {
|
||||
Intent IntentType `json:"intent"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SubIntents []string `json:"sub_intents,omitempty"`
|
||||
SuggestedTone string `json:"suggested_tone,omitempty"` // empathetic, formal, casual
|
||||
KeyTopics []string `json:"key_topics,omitempty"`
|
||||
Intent IntentType `json:"intent"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SubIntents []string `json:"sub_intents,omitempty"`
|
||||
SuggestedTone string `json:"suggested_tone,omitempty"` // empathetic, formal, casual
|
||||
KeyTopics []string `json:"key_topics,omitempty"`
|
||||
}
|
||||
|
||||
// ClassifyIntentRequest is the DTO for intent classification.
|
||||
type ClassifyIntentRequest struct {
|
||||
Message string `json:"message" validate:"required"`
|
||||
Message string `json:"message" validate:"required"`
|
||||
Language string `json:"language,omitempty"` // optional language hint
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (s *IntentService) ClassifyIntent(ctx context.Context, req *ClassifyIntentR
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: "gpt-4",
|
||||
Model: "",
|
||||
Messages: messages,
|
||||
Temperature: 0.1, // Low temperature for consistent classification
|
||||
MaxTokens: 256,
|
||||
@@ -126,7 +126,7 @@ func extractJSON(s string) string {
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start != -1 && end != -1 && end > start {
|
||||
return s[start:end+1]
|
||||
return s[start : end+1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -155,4 +155,4 @@ func parseIntentFromText(text string) IntentType {
|
||||
}
|
||||
}
|
||||
return IntentTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +823,7 @@ func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req
|
||||
|
||||
// TranslateInConversation translates a message scoped to a conversation route and caches the result.
|
||||
func (s *MessageService) TranslateInConversation(ctx context.Context, accountID, conversationID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -440,7 +440,12 @@ func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, ac
|
||||
}
|
||||
|
||||
userType := user.Type
|
||||
if userType == "" {
|
||||
if user.Role == string(model.UserTypeSuperAdmin) {
|
||||
// Chatwoot models platform administrators through STI, so the profile
|
||||
// serializer exposes `type: "SuperAdmin"`. GoChat stores the equivalent
|
||||
// platform role separately and normalizes it at the API boundary.
|
||||
userType = "SuperAdmin"
|
||||
} else if userType == "" {
|
||||
userType = "User"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
// RAGService implements Retrieval-Augmented Generation for Captain knowledge base Q&A.
|
||||
@@ -38,11 +38,11 @@ type RAGQueryResult struct {
|
||||
|
||||
// RAGSource references a source FAQ response used in the answer.
|
||||
type RAGSource struct {
|
||||
ResponseID uint `json:"response_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
DocumentID uint `json:"document_id,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
ResponseID uint `json:"response_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
DocumentID uint `json:"document_id,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// AssistantRepoIface defines the repository interface RAGService needs from CaptainAssistantRepo.
|
||||
@@ -81,6 +81,7 @@ func NewRAGService(
|
||||
|
||||
// Query performs a RAG Q&A: embed question → search FAQs → generate answer.
|
||||
func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryRequest) (*RAGQueryResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
if req.TopK <= 0 {
|
||||
req.TopK = 5
|
||||
}
|
||||
@@ -103,7 +104,7 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
|
||||
|
||||
// Step 2: Generate embedding for the question
|
||||
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Model: "text-embedding-3-small",
|
||||
Model: "",
|
||||
Input: []string{req.Question},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -169,9 +170,6 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
|
||||
|
||||
// Step 6: Call LLM for answer generation
|
||||
modelName := cfg.Model
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4"
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.3 // lower temp for factual answers
|
||||
@@ -212,16 +210,13 @@ func (s *RAGService) queryWithoutContext(ctx context.Context, assistant *model.C
|
||||
systemPrompt += "\n\nNote: No relevant FAQ entries were found for this question. Answer based on your general knowledge, but indicate that the answer may not be specific to the product."
|
||||
|
||||
modelName := cfg.Model
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4"
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.5
|
||||
}
|
||||
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Model: modelName,
|
||||
Model: modelName,
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: question},
|
||||
@@ -256,7 +251,7 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error {
|
||||
// Generate embedding from question + answer for better semantic matching
|
||||
inputText := fmt.Sprintf("Q: %s\nA: %s", resp.Question, resp.Answer)
|
||||
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
||||
Model: "text-embedding-3-small",
|
||||
Model: "",
|
||||
Input: []string{inputText},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -278,4 +273,4 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error {
|
||||
|
||||
applogger.L().Infof("RAG indexed response %d for assistant %d", resp.ID, resp.AssistantID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ func (s *ToolExecutionService) RunToolCallLoop(
|
||||
maxTokens int,
|
||||
maxIterations int,
|
||||
) (string, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
if s.llmProvider == nil {
|
||||
return "", fmt.Errorf("LLM provider not configured")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Restore the legacy 1536-dimensional column. Embeddings with any other
|
||||
-- dimension cannot be represented and are cleared during rollback.
|
||||
ALTER TABLE article_embeddings
|
||||
ALTER COLUMN vector_embedding TYPE vector(1536)
|
||||
USING CASE
|
||||
WHEN vector_embedding IS NULL THEN NULL
|
||||
WHEN vector_dims(vector_embedding) = 1536 THEN vector_embedding::vector(1536)
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_article_embeddings_vector
|
||||
ON article_embeddings USING ivfflat (vector_embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Copilot embedding dimensions are configured at runtime. Remove the fixed
|
||||
-- vector(1536) typmod so rebuilt article embeddings can use the selected model.
|
||||
DROP INDEX IF EXISTS idx_article_embeddings_vector;
|
||||
|
||||
ALTER TABLE article_embeddings
|
||||
ALTER COLUMN vector_embedding TYPE vector
|
||||
USING vector_embedding::vector;
|
||||
@@ -11,27 +11,33 @@ type ErrorCode string
|
||||
|
||||
const (
|
||||
// General errors
|
||||
ErrInternal ErrorCode = "INTERNAL_ERROR"
|
||||
ErrNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrBadRequest ErrorCode = "BAD_REQUEST"
|
||||
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrConflict ErrorCode = "CONFLICT"
|
||||
ErrValidation ErrorCode = "VALIDATION_ERROR"
|
||||
ErrRateLimit ErrorCode = "RATE_LIMITED"
|
||||
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
ErrInternal ErrorCode = "INTERNAL_ERROR"
|
||||
ErrNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrBadRequest ErrorCode = "BAD_REQUEST"
|
||||
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrConflict ErrorCode = "CONFLICT"
|
||||
ErrValidation ErrorCode = "VALIDATION_ERROR"
|
||||
ErrRateLimit ErrorCode = "RATE_LIMITED"
|
||||
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
ErrPaymentRequired ErrorCode = "PAYMENT_REQUIRED"
|
||||
|
||||
// Business-specific errors (ref: Chatwoot error patterns)
|
||||
ErrAccountNotFound ErrorCode = "ACCOUNT_NOT_FOUND"
|
||||
ErrInboxNotFound ErrorCode = "INBOX_NOT_FOUND"
|
||||
ErrChannelInvalid ErrorCode = "CHANNEL_INVALID"
|
||||
ErrContactNotFound ErrorCode = "CONTACT_NOT_FOUND"
|
||||
ErrConversationNotFound ErrorCode = "CONVERSATION_NOT_FOUND"
|
||||
ErrMessageNotFound ErrorCode = "MESSAGE_NOT_FOUND"
|
||||
ErrUserNotFound ErrorCode = "USER_NOT_FOUND"
|
||||
ErrDuplicateRecord ErrorCode = "DUPLICATE_RECORD"
|
||||
ErrChannelNotEnabled ErrorCode = "CHANNEL_NOT_ENABLED"
|
||||
ErrAccountNotFound ErrorCode = "ACCOUNT_NOT_FOUND"
|
||||
ErrInboxNotFound ErrorCode = "INBOX_NOT_FOUND"
|
||||
ErrChannelInvalid ErrorCode = "CHANNEL_INVALID"
|
||||
ErrContactNotFound ErrorCode = "CONTACT_NOT_FOUND"
|
||||
ErrConversationNotFound ErrorCode = "CONVERSATION_NOT_FOUND"
|
||||
ErrMessageNotFound ErrorCode = "MESSAGE_NOT_FOUND"
|
||||
ErrUserNotFound ErrorCode = "USER_NOT_FOUND"
|
||||
ErrDuplicateRecord ErrorCode = "DUPLICATE_RECORD"
|
||||
ErrChannelNotEnabled ErrorCode = "CHANNEL_NOT_ENABLED"
|
||||
ErrCopilotNotConfigured ErrorCode = "COPILOT_NOT_CONFIGURED"
|
||||
ErrCopilotProviderAuth ErrorCode = "COPILOT_PROVIDER_AUTHENTICATION_FAILED"
|
||||
ErrCopilotProviderRateLimited ErrorCode = "COPILOT_PROVIDER_RATE_LIMITED"
|
||||
ErrCopilotProviderUnreachable ErrorCode = "COPILOT_PROVIDER_UNREACHABLE"
|
||||
ErrCopilotProviderTimeout ErrorCode = "COPILOT_PROVIDER_TIMEOUT"
|
||||
ErrCopilotModelNotFound ErrorCode = "COPILOT_MODEL_NOT_FOUND"
|
||||
|
||||
// Knowledge Base / Help Center errors (M4)
|
||||
ErrPortalNotFound ErrorCode = "PORTAL_NOT_FOUND"
|
||||
@@ -63,9 +69,9 @@ func (e *AppError) Error() string {
|
||||
// NewAppError creates a new AppError
|
||||
func NewAppError(code ErrorCode, message string, status int) *AppError {
|
||||
return &AppError{
|
||||
Code: code,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Status: status,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +128,12 @@ func ErrorToHTTPStatus(code ErrorCode) int {
|
||||
return http.StatusForbidden
|
||||
case ErrConflict, ErrDuplicateRecord, ErrChannelInvalid:
|
||||
return http.StatusConflict
|
||||
case ErrRateLimit:
|
||||
case ErrRateLimit, ErrCopilotProviderRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrServiceUnavail:
|
||||
case ErrCopilotProviderTimeout:
|
||||
return http.StatusGatewayTimeout
|
||||
case ErrServiceUnavail, ErrCopilotNotConfigured, ErrCopilotProviderAuth,
|
||||
ErrCopilotProviderUnreachable, ErrCopilotModelNotFound:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
|
||||
Reference in New Issue
Block a user