* fix(captain): restore inbox takeover and KB citations * fix(captain): harden grounded citations and smoke seed --------- Co-authored-by: Rogee <rogee@ipao.vip>
1004 lines
40 KiB
Go
1004 lines
40 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/pgvector/pgvector-go"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// CaptainAssistantService implements business logic for CaptainAssistant operations.
|
|
// Reference: Chatwoot enterprise/app/controllers/api/v1/captain/assistants_controller.rb
|
|
type CaptainAssistantService struct {
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
inboxRepo *repository.CaptainInboxRepo
|
|
documentRepo *repository.CaptainDocumentRepo
|
|
responseRepo ResponseRepoIface
|
|
llmProvider llm.Provider
|
|
cache *redis.Client
|
|
promptBuilder *SystemPromptBuilder
|
|
toolExecSvc *ToolExecutionService
|
|
}
|
|
|
|
var (
|
|
ErrCaptainAssistantDisabled = errors.New("Captain assistant is disabled")
|
|
ErrCaptainKnowledgeRetrieval = errors.New("Captain knowledge retrieval failed")
|
|
)
|
|
|
|
// NewCaptainAssistantService creates a new CaptainAssistantService.
|
|
func NewCaptainAssistantService(
|
|
assistantRepo *repository.CaptainAssistantRepo,
|
|
inboxRepo *repository.CaptainInboxRepo,
|
|
documentRepo *repository.CaptainDocumentRepo,
|
|
responseRepo *repository.CaptainAssistantResponseRepo,
|
|
llmProvider llm.Provider,
|
|
cache ...*redis.Client,
|
|
) *CaptainAssistantService {
|
|
svc := &CaptainAssistantService{
|
|
assistantRepo: assistantRepo,
|
|
inboxRepo: inboxRepo,
|
|
documentRepo: documentRepo,
|
|
llmProvider: llmProvider,
|
|
promptBuilder: NewSystemPromptBuilder(),
|
|
}
|
|
if responseRepo != nil {
|
|
svc.responseRepo = responseRepo
|
|
}
|
|
if len(cache) > 0 {
|
|
svc.cache = cache[0]
|
|
}
|
|
return svc
|
|
}
|
|
|
|
func (s *CaptainAssistantService) SetToolExecutionService(toolExecSvc *ToolExecutionService) {
|
|
s.toolExecSvc = toolExecSvc
|
|
}
|
|
|
|
// --- Request DTOs ---
|
|
|
|
// CreateAssistantRequest is the DTO for creating an assistant.
|
|
type CreateAssistantRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Description string `json:"description"`
|
|
Config json.RawMessage `json:"config"`
|
|
Guardrails json.RawMessage `json:"guardrails"`
|
|
ResponseGuidelines json.RawMessage `json:"response_guidelines"`
|
|
}
|
|
|
|
// UpdateAssistantRequest is the DTO for updating an assistant.
|
|
type UpdateAssistantRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Config json.RawMessage `json:"config"`
|
|
Guardrails json.RawMessage `json:"guardrails"`
|
|
ResponseGuidelines json.RawMessage `json:"response_guidelines"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type PlaygroundMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
}
|
|
|
|
type PlaygroundRequest struct {
|
|
MessageContent string `json:"message_content"`
|
|
MessageHistory []PlaygroundMessage `json:"message_history"`
|
|
}
|
|
|
|
type CaptainMetricValue struct {
|
|
Current float64 `json:"current"`
|
|
Previous float64 `json:"previous"`
|
|
Trend float64 `json:"trend"`
|
|
}
|
|
|
|
type CaptainKnowledgeStats struct {
|
|
Approved int64 `json:"approved"`
|
|
Pending int64 `json:"pending"`
|
|
Documents int64 `json:"documents"`
|
|
Coverage float64 `json:"coverage"`
|
|
}
|
|
|
|
type CaptainAssistantStats struct {
|
|
ConversationsHandled CaptainMetricValue `json:"conversations_handled"`
|
|
AutoResolutionRate CaptainMetricValue `json:"auto_resolution_rate"`
|
|
HandoffRate CaptainMetricValue `json:"handoff_rate"`
|
|
HoursSaved CaptainMetricValue `json:"hours_saved"`
|
|
ReopenRate CaptainMetricValue `json:"reopen_rate"`
|
|
ConversationDepth CaptainMetricValue `json:"conversation_depth"`
|
|
Knowledge CaptainKnowledgeStats `json:"knowledge"`
|
|
}
|
|
|
|
type CaptainDrilldownParams struct {
|
|
Metric string
|
|
Range string
|
|
TimezoneOffset float64
|
|
Page int
|
|
PerPage int
|
|
}
|
|
|
|
type CaptainDrilldownResult struct {
|
|
Meta map[string]any `json:"meta"`
|
|
Payload []map[string]any `json:"payload"`
|
|
}
|
|
|
|
var captainReportReasons = map[string]bool{
|
|
"incorrect_information": true, "inappropriate_response": true, "incomplete_response": true,
|
|
"outdated_information": true, "other": true,
|
|
}
|
|
|
|
var captainResolvedEvents = []string{"conversation_captain_inference_resolved", "conversation_bot_resolved"}
|
|
var captainHandoffEvents = []string{"conversation_captain_inference_handoff", "conversation_bot_handoff"}
|
|
|
|
// --- CRUD Operations ---
|
|
|
|
// Create creates a new CaptainAssistant.
|
|
func (s *CaptainAssistantService) Create(ctx context.Context, accountID uint, req *CreateAssistantRequest) (*model.CaptainAssistant, error) {
|
|
if req.Name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if req.Description == "" {
|
|
return nil, fmt.Errorf("description is required")
|
|
}
|
|
config := req.Config
|
|
if len(config) == 0 || string(config) == "null" {
|
|
defaultCfg := model.DefaultAssistantConfig()
|
|
data, err := json.Marshal(defaultCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal default config: %w", err)
|
|
}
|
|
config = data
|
|
}
|
|
|
|
assistant := &model.CaptainAssistant{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Config: config,
|
|
Guardrails: req.Guardrails,
|
|
ResponseGuidelines: req.ResponseGuidelines,
|
|
Status: model.AssistantStatusActive,
|
|
}
|
|
|
|
if err := s.assistantRepo.Create(ctx, assistant); err != nil {
|
|
applogger.L().Errorf("Create captain assistant: %v", err)
|
|
return nil, fmt.Errorf("create assistant: %w", err)
|
|
}
|
|
return assistant, nil
|
|
}
|
|
|
|
// Get retrieves an assistant by ID scoped to an account.
|
|
func (s *CaptainAssistantService) Get(ctx context.Context, accountID, id uint) (*model.CaptainAssistant, error) {
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("Get captain assistant: %v", err)
|
|
return nil, fmt.Errorf("get assistant: %w", err)
|
|
}
|
|
return assistant, nil
|
|
}
|
|
|
|
// Update updates an existing assistant scoped to an account.
|
|
func (s *CaptainAssistantService) Update(ctx context.Context, accountID, id uint, req *UpdateAssistantRequest) (*model.CaptainAssistant, error) {
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
|
|
if req.Name != "" {
|
|
assistant.Name = req.Name
|
|
}
|
|
if req.Description != "" {
|
|
assistant.Description = req.Description
|
|
}
|
|
if len(req.Config) > 0 && string(req.Config) != "null" {
|
|
assistant.Config = req.Config
|
|
}
|
|
if len(req.Guardrails) > 0 && string(req.Guardrails) != "null" {
|
|
assistant.Guardrails = req.Guardrails
|
|
}
|
|
if len(req.ResponseGuidelines) > 0 && string(req.ResponseGuidelines) != "null" {
|
|
assistant.ResponseGuidelines = req.ResponseGuidelines
|
|
}
|
|
if req.Status != "" {
|
|
assistant.Status = model.AssistantStatus(req.Status)
|
|
}
|
|
|
|
if err := s.assistantRepo.Update(ctx, assistant); err != nil {
|
|
applogger.L().Errorf("Update captain assistant: %v", err)
|
|
return nil, fmt.Errorf("update assistant: %w", err)
|
|
}
|
|
return assistant, nil
|
|
}
|
|
|
|
// Delete deletes an assistant by ID scoped to an account.
|
|
func (s *CaptainAssistantService) Delete(ctx context.Context, accountID, id uint) error {
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id); err != nil {
|
|
return fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if err := s.assistantRepo.Delete(ctx, id); err != nil {
|
|
applogger.L().Errorf("Delete captain assistant: %v", err)
|
|
return fmt.Errorf("delete assistant: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List retrieves assistants for an account with pagination.
|
|
func (s *CaptainAssistantService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.CaptainAssistant, int64, error) {
|
|
assistants, count, err := s.assistantRepo.ListByAccount(ctx, accountID, offset, limit)
|
|
if err != nil {
|
|
applogger.L().Errorf("List captain assistants: %v", err)
|
|
return nil, 0, fmt.Errorf("list assistants: %w", err)
|
|
}
|
|
return assistants, count, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) Stats(ctx context.Context, accountID, assistantID uint, rangeValue string, timezoneOffset float64) (*CaptainAssistantStats, error) {
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
|
return nil, err
|
|
}
|
|
currentStart, currentEnd, previousStart, previousEnd := captainStatsWindows(rangeValue, timezoneOffset, time.Now())
|
|
current, err := s.captainWindowMetrics(ctx, accountID, assistantID, currentStart, currentEnd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
previous, err := s.captainWindowMetrics(ctx, accountID, assistantID, previousStart, previousEnd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
knowledge, err := s.captainKnowledgeStats(ctx, assistantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CaptainAssistantStats{
|
|
ConversationsHandled: captainPack(current.handled, previous.handled, true),
|
|
AutoResolutionRate: captainPack(current.autoResolutionRate, previous.autoResolutionRate, false),
|
|
HandoffRate: captainPack(current.handoffRate, previous.handoffRate, false),
|
|
HoursSaved: captainPack(current.hoursSaved, previous.hoursSaved, true),
|
|
ReopenRate: captainPack(current.reopenRate, previous.reopenRate, false),
|
|
ConversationDepth: captainPack(current.depth, previous.depth, false),
|
|
Knowledge: knowledge,
|
|
}, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) Summary(ctx context.Context, accountID, assistantID, userID uint, rangeValue string, timezoneOffset float64) (string, error) {
|
|
cacheKey := fmt.Sprintf("captain_overview_summary/%d/%d/%s/%s", assistantID, userID, rangeValue, time.Now().Format("2006-01-02"))
|
|
if s.cache != nil {
|
|
if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil {
|
|
return cached, nil
|
|
}
|
|
}
|
|
if s.llmProvider == nil {
|
|
return "", fmt.Errorf("Captain summary is unavailable")
|
|
}
|
|
stats, err := s.Stats(ctx, accountID, assistantID, rangeValue, timezoneOffset)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var user model.User
|
|
_ = s.assistantRepo.DB().WithContext(ctx).First(&user, userID).Error
|
|
firstName := ""
|
|
if names := strings.Fields(user.Name); len(names) > 0 {
|
|
firstName = names[0]
|
|
}
|
|
statsJSON, _ := json.Marshal(stats)
|
|
response, err := s.llmProvider.ChatCompletion(llm.WithAccountFeature(ctx, accountID, "assistant"), llm.ChatRequest{
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: "Summarize these Captain support metrics in one concise, useful paragraph. Address the user by first name when available. Return only the summary."},
|
|
{Role: "user", Content: fmt.Sprintf("User: %s\nPeriod: %s\nMetrics: %s", firstName, rangeValue, statsJSON)},
|
|
},
|
|
Temperature: 0.3,
|
|
MaxTokens: 300,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if response == nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" {
|
|
return "", fmt.Errorf("Captain summary is unavailable")
|
|
}
|
|
message := strings.TrimSpace(response.Choices[0].Message.Content)
|
|
if s.cache != nil {
|
|
_ = s.cache.Set(ctx, cacheKey, message, time.Hour).Err()
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
type captainWindowMetricValues struct {
|
|
handled, autoResolutionRate, handoffRate, hoursSaved, reopenRate, depth float64
|
|
}
|
|
|
|
func (s *CaptainAssistantService) captainWindowMetrics(ctx context.Context, accountID, assistantID uint, since, until time.Time) (captainWindowMetricValues, error) {
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
base := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until)
|
|
var handled int64
|
|
if err := base.Distinct("conversation_id").Count(&handled).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
var publicReplies int64
|
|
if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Count(&publicReplies).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
var depthConversations int64
|
|
if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Distinct("conversation_id").Count(&depthConversations).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
var handledIDs []uint
|
|
if err := base.Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
resolved, handoffs, reopened := int64(0), int64(0), int64(0)
|
|
if len(handledIDs) > 0 {
|
|
resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until)
|
|
if err := resolvedEvents.Distinct("conversation_id").Count(&resolved).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
if err := db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until).Distinct("conversation_id").Count(&handoffs).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
resolvedSubquery := resolvedEvents.Select("conversation_id, event_end_time")
|
|
if err := db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedSubquery).
|
|
Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until).
|
|
Distinct("reopens.conversation_id").Count(&reopened).Error; err != nil {
|
|
return captainWindowMetricValues{}, err
|
|
}
|
|
}
|
|
return captainWindowMetricValues{
|
|
handled: float64(handled), autoResolutionRate: captainRate(resolved, handled), handoffRate: captainRate(handoffs, handled),
|
|
hoursSaved: math.Round(float64(publicReplies) * 120 / 3600), reopenRate: captainRate(reopened, resolved),
|
|
depth: captainDivide(publicReplies, depthConversations),
|
|
}, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) captainResolvedEventQuery(ctx context.Context, accountID uint, handledIDs []uint, since, until time.Time) *gorm.DB {
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
handoffIDs := db.Model(&model.ReportingEvent{}).Select("conversation_id").Where("account_id = ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, captainHandoffEvents, since, until)
|
|
return db.Model(&model.ReportingEvent{}).
|
|
Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainResolvedEvents, since, until).
|
|
Where("NOT (name = ? AND conversation_id IN (?))", "conversation_bot_resolved", handoffIDs)
|
|
}
|
|
|
|
func (s *CaptainAssistantService) captainKnowledgeStats(ctx context.Context, assistantID uint) (CaptainKnowledgeStats, error) {
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
var approved, pending, documents int64
|
|
if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusApproved).Count(&approved).Error; err != nil {
|
|
return CaptainKnowledgeStats{}, err
|
|
}
|
|
if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusPending).Count(&pending).Error; err != nil {
|
|
return CaptainKnowledgeStats{}, err
|
|
}
|
|
if err := db.Model(&model.CaptainDocument{}).Where("assistant_id = ?", assistantID).Count(&documents).Error; err != nil {
|
|
return CaptainKnowledgeStats{}, err
|
|
}
|
|
total := approved + pending
|
|
coverage := float64(0)
|
|
if total > 0 {
|
|
coverage = math.Round(float64(approved) / float64(total) * 100)
|
|
}
|
|
return CaptainKnowledgeStats{Approved: approved, Pending: pending, Documents: documents, Coverage: coverage}, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) Drilldown(ctx context.Context, accountID, assistantID uint, params CaptainDrilldownParams) (*CaptainDrilldownResult, error) {
|
|
supported := map[string]bool{"conversations_handled": true, "auto_resolution_rate": true, "handoff_rate": true, "reopen_rate": true}
|
|
if !supported[params.Metric] {
|
|
return nil, fmt.Errorf("unsupported metric")
|
|
}
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
|
return nil, err
|
|
}
|
|
since, until, _, _ := captainStatsWindows(params.Range, params.TimezoneOffset, time.Now())
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
var handledIDs []uint
|
|
if err := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until).Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ids := handledIDs
|
|
if params.Metric != "conversations_handled" {
|
|
var eventIDs []uint
|
|
resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until)
|
|
query := resolvedEvents
|
|
conversationIDColumn := "conversation_id"
|
|
if params.Metric == "handoff_rate" {
|
|
query = db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until)
|
|
} else if params.Metric == "reopen_rate" {
|
|
query = db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedEvents.Select("conversation_id, event_end_time")).
|
|
Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until)
|
|
conversationIDColumn = "reopens.conversation_id"
|
|
}
|
|
if err := query.Distinct().Pluck(conversationIDColumn, &eventIDs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ids = eventIDs
|
|
}
|
|
params.Page = max(params.Page, 1)
|
|
if params.PerPage <= 0 {
|
|
params.PerPage = 25
|
|
}
|
|
if params.PerPage > 100 {
|
|
params.PerPage = 100
|
|
}
|
|
var total int64
|
|
query := db.Model(&model.Conversation{}).Where("account_id = ? AND id IN ?", accountID, ids)
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var conversations []model.Conversation
|
|
if err := query.Preload("Contact").Preload("Inbox").Preload("Assignee").Order("created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]map[string]any, 0, len(conversations))
|
|
for i := range conversations {
|
|
payload = append(payload, s.captainConversationDrilldownRecord(ctx, &conversations[i]))
|
|
}
|
|
return &CaptainDrilldownResult{Meta: map[string]any{"metric": params.Metric, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": total, "range": map[string]any{"since": since.Unix(), "until": until.Unix()}}, Payload: payload}, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) captainConversationDrilldownRecord(ctx context.Context, conversation *model.Conversation) map[string]any {
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
var last model.Message
|
|
_ = db.Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error
|
|
var lastPayload any
|
|
if last.ID != 0 {
|
|
lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": nil, "created_at": last.CreatedAt.Unix()}
|
|
}
|
|
assigneeName := ""
|
|
if conversation.Assignee != nil {
|
|
assigneeName = conversation.Assignee.Name
|
|
}
|
|
return map[string]any{"record_type": "conversation", "conversation": map[string]any{
|
|
"id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name,
|
|
"inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName,
|
|
"status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": valueOrZero(conversation.LastActivityAt), "last_message": lastPayload,
|
|
}, "message": nil, "metric_value": nil, "occurred_at": conversation.CreatedAt.Unix()}
|
|
}
|
|
|
|
func (s *CaptainAssistantService) CreateMessageReport(ctx context.Context, accountID, userID, messageID uint, reason, description string) (*model.CaptainMessageReport, error) {
|
|
if !captainReportReasons[reason] {
|
|
return nil, fmt.Errorf("invalid report_reason")
|
|
}
|
|
var message model.Message
|
|
if err := s.assistantRepo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, messageID).First(&message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if message.SenderType != "Captain::Assistant" && message.SenderType != "CaptainAssistant" && message.SenderType != "captain_assistant" {
|
|
return nil, fmt.Errorf("Only Captain messages can be reported")
|
|
}
|
|
report := &model.CaptainMessageReport{AccountID: accountID, ConversationID: message.ConversationID, MessageID: message.ID, UserID: userID, ReportReason: reason, Description: description}
|
|
if err := s.assistantRepo.DB().WithContext(ctx).Create(report).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func captainStatsWindows(rangeValue string, timezoneOffset float64, now time.Time) (time.Time, time.Time, time.Time, time.Time) {
|
|
allowed := map[string]bool{"7": true, "30": true, "90": true, "this_month": true, "last_month": true}
|
|
if !allowed[rangeValue] {
|
|
rangeValue = "30"
|
|
}
|
|
loc := time.FixedZone("captain", int(timezoneOffset*3600))
|
|
localNow := now.In(loc)
|
|
if rangeValue == "this_month" || rangeValue == "last_month" {
|
|
start := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, loc)
|
|
end := localNow
|
|
if rangeValue == "last_month" {
|
|
end = start.Add(-time.Nanosecond)
|
|
start = time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, loc)
|
|
}
|
|
previousEnd := start.Add(-time.Nanosecond)
|
|
previousStart := time.Date(previousEnd.Year(), previousEnd.Month(), 1, 0, 0, 0, 0, loc)
|
|
if rangeValue == "this_month" {
|
|
elapsed := end.Sub(start)
|
|
candidate := previousStart.Add(elapsed)
|
|
if candidate.Before(previousEnd) {
|
|
previousEnd = candidate
|
|
}
|
|
}
|
|
return start.UTC(), end.UTC(), previousStart.UTC(), previousEnd.UTC()
|
|
}
|
|
days, _ := strconv.Atoi(rangeValue)
|
|
duration := time.Duration(days) * 24 * time.Hour
|
|
return now.Add(-duration), now, now.Add(-2 * duration), now.Add(-duration)
|
|
}
|
|
|
|
func captainRate(numerator, denominator int64) float64 {
|
|
if denominator == 0 {
|
|
return 0
|
|
}
|
|
return math.Round(float64(numerator)/float64(denominator)*1000) / 10
|
|
}
|
|
func captainDivide(numerator, denominator int64) float64 {
|
|
if denominator == 0 {
|
|
return 0
|
|
}
|
|
return math.Round(float64(numerator)/float64(denominator)*10) / 10
|
|
}
|
|
func captainPack(current, previous float64, percent bool) CaptainMetricValue {
|
|
trend := current - previous
|
|
if percent {
|
|
if previous == 0 {
|
|
trend = 0
|
|
} else {
|
|
trend = (current - previous) / previous * 100
|
|
}
|
|
}
|
|
return CaptainMetricValue{Current: current, Previous: previous, Trend: math.Round(trend*10) / 10}
|
|
}
|
|
func valueOrZero(value *int64) int64 {
|
|
if value == nil {
|
|
return 0
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// --- Config Management ---
|
|
|
|
// GetConfig reads and parses the assistant's JSONB config.
|
|
func (s *CaptainAssistantService) GetConfig(ctx context.Context, id uint) (*model.AssistantConfig, error) {
|
|
assistant, err := s.assistantRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
return assistant.GetConfig()
|
|
}
|
|
|
|
// SetConfig updates the assistant's JSONB config.
|
|
func (s *CaptainAssistantService) SetConfig(ctx context.Context, id uint, cfg *model.AssistantConfig) error {
|
|
assistant, err := s.assistantRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if err := assistant.SetConfig(cfg); err != nil {
|
|
return fmt.Errorf("set config: %w", err)
|
|
}
|
|
if err := s.assistantRepo.Update(ctx, assistant); err != nil {
|
|
applogger.L().Errorf("SetConfig captain assistant: %v", err)
|
|
return fmt.Errorf("update assistant config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Inbox Association ---
|
|
|
|
// AssociateInbox binds an assistant to an inbox.
|
|
func (s *CaptainAssistantService) AssociateInbox(ctx context.Context, assistantID, inboxID, accountID uint) (*model.Inbox, error) {
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
inbox, err := s.inboxRepo.FindAccountInbox(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
err = db.Transaction(func(tx *gorm.DB) error {
|
|
ci := &model.CaptainInbox{AssistantID: assistantID, InboxID: inboxID, AccountID: accountID}
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "inbox_id"}},
|
|
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}},
|
|
DoNothing: true,
|
|
}).Create(ci).Error; err != nil {
|
|
return err
|
|
}
|
|
var persisted model.CaptainInbox
|
|
if err := tx.Where("inbox_id = ?", inboxID).First(&persisted).Error; err != nil {
|
|
return err
|
|
}
|
|
if persisted.AssistantID != assistantID {
|
|
return errors.New("inbox is already associated with another assistant")
|
|
}
|
|
|
|
_, err := ensureCaptainAgentBotBinding(ctx, tx, assistant, inboxID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("AssociateInbox: %v", err)
|
|
return nil, fmt.Errorf("associate inbox: %w", err)
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// DissociateInbox unbinds an assistant from an inbox.
|
|
func (s *CaptainAssistantService) DissociateInbox(ctx context.Context, accountID, assistantID, inboxID uint) error {
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
|
return fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if ci, err := s.inboxRepo.FindByAssistantAndInbox(ctx, assistantID, inboxID); err != nil {
|
|
return fmt.Errorf("captain inbox not found: %w", err)
|
|
} else if ci.AccountID != accountID {
|
|
return fmt.Errorf("captain inbox not found")
|
|
}
|
|
db := s.assistantRepo.DB().WithContext(ctx)
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("account_id = ? AND captain_assistant_id = ? AND inbox_id = ?", accountID, assistantID, inboxID).Delete(&model.CaptainInbox{}).Error; err != nil {
|
|
return err
|
|
}
|
|
var bot model.AgentBot
|
|
if err := tx.Where("account_id = ? AND captain_assistant_id = ?", accountID, assistantID).First(&bot).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return tx.Where("inbox_id = ? AND agent_bot_id = ?", inboxID, bot.ID).Delete(&model.AgentBotInbox{}).Error
|
|
}); err != nil {
|
|
applogger.L().Errorf("DissociateInbox: %v", err)
|
|
return fmt.Errorf("dissociate inbox: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureCaptainAgentBotBinding(ctx context.Context, db *gorm.DB, assistant *model.CaptainAssistant, inboxID uint) (*model.AgentBot, error) {
|
|
var legacyBots []model.AgentBot
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND bot_type = ? AND captain_assistant_id IS NULL", assistant.AccountID, "captain").Find(&legacyBots).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range legacyBots {
|
|
if extractAssistantIDFromBotConfig(legacyBots[i].Config) == assistant.ID {
|
|
if err := db.WithContext(ctx).Model(&legacyBots[i]).Where("captain_assistant_id IS NULL").Update("captain_assistant_id", assistant.ID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
token, err := generateBotAccessToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
secret, err := generateBotSecret()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bot := &model.AgentBot{AccountID: &assistant.AccountID, CaptainAssistantID: &assistant.ID, Name: fmt.Sprintf("Captain Assistant #%d", assistant.ID), Description: assistant.Name, BotType: "captain", AccessToken: token, Secret: secret, Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
|
|
if err := db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "account_id"}, {Name: "captain_assistant_id"}},
|
|
DoNothing: true,
|
|
}).Create(bot).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var persisted model.AgentBot
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND captain_assistant_id = ?", assistant.AccountID, assistant.ID).First(&persisted).Error; err != nil {
|
|
return nil, fmt.Errorf("load captain bot: %w", err)
|
|
}
|
|
bot = &persisted
|
|
binding := &model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inboxID, AccountID: &assistant.AccountID, Status: model.AgentBotInboxActive}
|
|
if err := db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "agent_bot_id"}, {Name: "inbox_id"}},
|
|
DoUpdates: clause.Assignments(map[string]any{"status": model.AgentBotInboxActive, "account_id": assistant.AccountID}),
|
|
}).Create(binding).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return bot, nil
|
|
}
|
|
|
|
func (s *CaptainAssistantService) ListInboxes(ctx context.Context, accountID, assistantID uint) ([]model.Inbox, error) {
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
return s.inboxRepo.ListInboxesByAssistant(ctx, accountID, assistantID)
|
|
}
|
|
|
|
func (s *CaptainAssistantService) AvailableTools(ctx context.Context, accountID uint) []map[string]any {
|
|
return []map[string]any{
|
|
{"id": "add_contact_note", "title": "Add Contact Note", "description": "Add a note to a contact profile", "icon": "note-add"},
|
|
{"id": "add_private_note", "title": "Add Private Note", "description": "Add a private note to a conversation (internal only)", "icon": "eye-off"},
|
|
{"id": "update_priority", "title": "Update Priority", "description": "Update conversation priority level", "icon": "exclamation-triangle"},
|
|
{"id": "add_label_to_conversation", "title": "Add Label to Conversation", "description": "Add a label to a conversation", "icon": "tag"},
|
|
{"id": "faq_lookup", "title": "FAQ Lookup", "description": "Search FAQ responses using semantic similarity", "icon": "search"},
|
|
{"id": "resolve_conversation", "title": "Resolve Conversation", "description": "Resolve a conversation when the issue has been addressed", "icon": "checkmark"},
|
|
{"id": "handoff", "title": "Handoff to Human", "description": "Hand off the conversation to a human agent", "icon": "user-switch"},
|
|
}
|
|
}
|
|
|
|
// --- Document Management ---
|
|
|
|
// AddDocument creates a document linked to the assistant.
|
|
func (s *CaptainAssistantService) AddDocument(ctx context.Context, assistantID uint, doc *model.CaptainDocument) error {
|
|
doc.AssistantID = assistantID
|
|
if err := s.documentRepo.Create(ctx, doc); err != nil {
|
|
applogger.L().Errorf("AddDocument: %v", err)
|
|
return fmt.Errorf("add document: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveDocument removes a document from the assistant.
|
|
func (s *CaptainAssistantService) RemoveDocument(ctx context.Context, documentID uint) error {
|
|
if err := s.documentRepo.Delete(ctx, documentID); err != nil {
|
|
applogger.L().Errorf("RemoveDocument: %v", err)
|
|
return fmt.Errorf("remove document: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- RAG Response Generation ---
|
|
|
|
// GenerateResponse calls the LLM provider to generate a response based on RAG context.
|
|
// Reference: Chatwoot Captain::Assistant::ResponseGenerator
|
|
func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistantID uint, query string) (string, error) {
|
|
assistant, err := s.assistantRepo.GetByID(ctx, assistantID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if assistant.Status != model.AssistantStatusActive {
|
|
return "", fmt.Errorf("%w: status=%s", ErrCaptainAssistantDisabled, assistant.Status)
|
|
}
|
|
ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant")
|
|
|
|
// Build system prompt from assistant config and response guidelines
|
|
cfg, _ := assistant.GetConfig()
|
|
ctx = withAssistantGenerationConfig(ctx, cfg)
|
|
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
|
|
|
|
// Build messages for LLM
|
|
messages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: query},
|
|
}
|
|
|
|
req := llm.ChatRequest{
|
|
Model: cfg.Model,
|
|
Messages: messages,
|
|
Temperature: cfg.Temperature,
|
|
MaxTokens: 1024,
|
|
}
|
|
if s.llmProvider == nil {
|
|
return "", llm.ErrProviderNotConfigured
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, req)
|
|
if err != nil {
|
|
applogger.L().Errorf("GenerateResponse LLM call: %v", err)
|
|
return "", fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
return resp.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// GeneratePlaygroundResponse follows Chatwoot Captain assistant playground behavior.
|
|
func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context, accountID, assistantID uint, req PlaygroundRequest) (map[string]any, error) {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if assistant.Status != model.AssistantStatusActive {
|
|
return nil, fmt.Errorf("%w: status=%s", ErrCaptainAssistantDisabled, assistant.Status)
|
|
}
|
|
|
|
if s.captainV2Enabled(ctx, accountID) {
|
|
history := playgroundMessageHistory(req.MessageHistory, req.MessageContent)
|
|
content, err := s.generatePlaygroundLLMResponse(ctx, assistant, history)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"response": content}, nil
|
|
}
|
|
|
|
history := append([]PlaygroundMessage{}, req.MessageHistory...)
|
|
content, err := s.generatePlaygroundLLMResponse(ctx, assistant, appendAdditionalPlaygroundMessage(history, req.MessageContent))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"content": content}, nil
|
|
}
|
|
|
|
func captainPlaygroundV2ErrorResponse(err error) map[string]any {
|
|
return map[string]any{
|
|
"response": "conversation_handoff",
|
|
"reasoning": fmt.Sprintf("Error occurred: %v", err),
|
|
"handoff_tool_called": false,
|
|
}
|
|
}
|
|
|
|
func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Context, assistant *model.CaptainAssistant, history []PlaygroundMessage) (string, error) {
|
|
if s.llmProvider == nil {
|
|
return "", llm.ErrProviderNotConfigured
|
|
}
|
|
|
|
cfg, _ := assistant.GetConfig()
|
|
ctx = withAssistantGenerationConfig(ctx, cfg)
|
|
|
|
// RAG: embed the latest user message and search approved FAQ responses.
|
|
// This mirrors Chatwoot's Captain playground which injects knowledge base context.
|
|
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
|
|
ragContext, err := s.retrieveFAQContext(ctx, assistant.ID, history, cfg.FeatureCitation)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ragContext != "" {
|
|
systemPrompt += "\n\n" + ragContext
|
|
systemPrompt += "\n\nUse the above FAQ entries as reference when answering. If the FAQ entries are not relevant, rely on your general knowledge."
|
|
if cfg.FeatureCitation {
|
|
systemPrompt += " Cite each used sourced FAQ as [[n](URL)] with its exact Source URL; never invent a URL."
|
|
}
|
|
}
|
|
|
|
messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}}
|
|
for _, message := range history {
|
|
if message.Role == "" || message.Content == "" {
|
|
continue
|
|
}
|
|
messages = append(messages, llm.ChatMessage{Role: message.Role, Content: message.Content})
|
|
}
|
|
|
|
if s.toolExecSvc != nil {
|
|
content, skillsBound, err := s.toolExecSvc.RunAssistantToolCallLoop(ctx, CaptainToolScope{
|
|
AccountID: assistant.AccountID, AssistantID: assistant.ID,
|
|
}, messages, cfg.Model, cfg.Temperature, 1024, 5, true)
|
|
if err != nil {
|
|
if skillsBound {
|
|
return "", fmt.Errorf("captain skill runtime unavailable: %w", err)
|
|
}
|
|
return "", fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
if strings.TrimSpace(content) == "" {
|
|
return "", fmt.Errorf("no response from LLM")
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: cfg.Model,
|
|
Messages: messages,
|
|
Temperature: cfg.Temperature,
|
|
MaxTokens: 1024,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("GeneratePlaygroundResponse LLM call: %v", err)
|
|
return "", fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
if len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("no response from LLM")
|
|
}
|
|
return resp.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// retrieveFAQContext generates an embedding for the latest user message,
|
|
// searches approved FAQ responses via pgvector, and returns formatted context.
|
|
// Returns empty string when there is no user query or no matching approved knowledge.
|
|
// feature_faq controls FAQ generation from resolved conversations upstream; it
|
|
// does not disable retrieval of already-approved knowledge.
|
|
func (s *CaptainAssistantService) retrieveFAQContext(ctx context.Context, assistantID uint, history []PlaygroundMessage, requireSource bool) (string, error) {
|
|
// Extract the latest user message
|
|
userMsg := ""
|
|
for i := len(history) - 1; i >= 0; i-- {
|
|
if history[i].Role == "user" {
|
|
userMsg = history[i].Content
|
|
break
|
|
}
|
|
}
|
|
if userMsg == "" {
|
|
return "", nil
|
|
}
|
|
// Generate embedding for the question
|
|
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
|
Input: []string{userMsg},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: generate FAQ query embedding: %w", ErrCaptainKnowledgeRetrieval, err)
|
|
}
|
|
if embedResp == nil || len(embedResp.Data) == 0 || len(embedResp.Data[0].Embedding) == 0 {
|
|
return "", fmt.Errorf("%w: provider returned an empty FAQ query embedding", ErrCaptainKnowledgeRetrieval)
|
|
}
|
|
|
|
float32Emb := make([]float32, len(embedResp.Data[0].Embedding))
|
|
for i, v := range embedResp.Data[0].Embedding {
|
|
float32Emb[i] = float32(v)
|
|
}
|
|
pgvectorEmb := pgvector.NewVector(float32Emb)
|
|
|
|
// Search approved FAQ responses by embedding similarity
|
|
if s.responseRepo == nil {
|
|
return "", ErrCaptainKnowledgeRetrieval
|
|
}
|
|
results, err := s.responseRepo.SearchByEmbedding(ctx, assistantID, pgvectorEmb, 5)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrCaptainKnowledgeRetrieval, err)
|
|
}
|
|
if len(results) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var contextParts []string
|
|
for _, r := range results {
|
|
if requireSource && !validCitationSource(r.SourceURL) {
|
|
continue
|
|
}
|
|
entry := fmt.Sprintf("[FAQ %d]\nQ: %s\nA: %s", len(contextParts)+1, r.Question, r.Answer)
|
|
if r.SourceURL != "" {
|
|
entry += "\nSource: " + r.SourceURL
|
|
}
|
|
contextParts = append(contextParts, entry)
|
|
}
|
|
if len(contextParts) == 0 {
|
|
return "", nil
|
|
}
|
|
return "Knowledge Base Context:\n" + strings.Join(contextParts, "\n\n"), nil
|
|
}
|
|
|
|
func validCitationSource(raw string) bool {
|
|
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
|
|
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
|
}
|
|
|
|
func withAssistantGenerationConfig(ctx context.Context, cfg *model.AssistantConfig) context.Context {
|
|
if cfg == nil || !cfg.TemperatureConfigured {
|
|
return ctx
|
|
}
|
|
return llm.WithTemperatureOverride(ctx, cfg.Temperature)
|
|
}
|
|
|
|
func (s *CaptainAssistantService) captainV2Enabled(ctx context.Context, accountID uint) bool {
|
|
flags, err := s.assistantRepo.GetAccountFeatureFlags(ctx, accountID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return featureFlagStringEnabled(flags, "captain_integration_v2")
|
|
}
|
|
|
|
func featureFlagStringEnabled(raw, flag string) bool {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
var objectFlags map[string]bool
|
|
if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil {
|
|
return objectFlags[flag]
|
|
}
|
|
var arrayFlags []string
|
|
if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil {
|
|
for _, item := range arrayFlags {
|
|
if item == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
for _, item := range strings.Split(raw, ",") {
|
|
if strings.TrimSpace(item) == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func playgroundMessageHistory(history []PlaygroundMessage, current string) []PlaygroundMessage {
|
|
result := append([]PlaygroundMessage{}, history...)
|
|
if strings.TrimSpace(current) == "" {
|
|
return result
|
|
}
|
|
currentMessage := PlaygroundMessage{Role: "user", Content: current}
|
|
if len(result) > 0 {
|
|
last := result[len(result)-1]
|
|
if last.Role == currentMessage.Role && last.Content == currentMessage.Content && last.AgentName == "" {
|
|
return result
|
|
}
|
|
}
|
|
return append(result, currentMessage)
|
|
}
|
|
|
|
func appendAdditionalPlaygroundMessage(history []PlaygroundMessage, current string) []PlaygroundMessage {
|
|
if strings.TrimSpace(current) == "" {
|
|
return history
|
|
}
|
|
return append(history, PlaygroundMessage{Role: "user", Content: current})
|
|
}
|