Files
gochat/internal/service/captain_task_extended_service.go
T
2026-06-04 15:44:48 +08:00

324 lines
10 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/repository"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
applogger "github.com/gochat/gochat/pkg/logger"
)
// CaptainTaskExtendedService provides label suggestion and follow-up features.
// These are GET-based endpoints that return AI-generated suggestions without modifying data.
// Reference: Chatwoot Captain::Llm::ConversationInsightService + M12 PRD
type CaptainTaskExtendedService struct {
conversationRepo *repository.ConversationRepo
messageRepo *repository.MessageRepo
assistantRepo *repository.CaptainAssistantRepo
preferenceRepo *repository.CaptainPreferenceRepo
llmProvider llm.Provider
}
func NewCaptainTaskExtendedService(
conversationRepo *repository.ConversationRepo,
messageRepo *repository.MessageRepo,
assistantRepo *repository.CaptainAssistantRepo,
preferenceRepo *repository.CaptainPreferenceRepo,
llmProvider llm.Provider,
) *CaptainTaskExtendedService {
return &CaptainTaskExtendedService{
conversationRepo: conversationRepo,
messageRepo: messageRepo,
assistantRepo: assistantRepo,
preferenceRepo: preferenceRepo,
llmProvider: llmProvider,
}
}
// --- Request/Response DTOs ---
// LabelSuggestionQuery is the input for label suggestion on multiple conversations.
type LabelSuggestionQuery struct {
ConversationIDs []uint `json:"conversation_ids" validate:"required,min=1"`
AssistantID uint `json:"assistant_id,omitempty"`
}
// ConversationLabelSuggestion holds label suggestions for a single conversation.
type ConversationLabelSuggestion struct {
ConversationID uint `json:"conversation_id"`
Labels []string `json:"labels"`
Priority string `json:"priority"` // urgent, high, medium, low
Reason string `json:"reason"`
}
// LabelSuggestionResult holds label suggestions for multiple conversations.
type LabelSuggestionResult struct {
Suggestions []ConversationLabelSuggestion `json:"suggestions"`
}
// FollowUpQuery is the input for follow-up task suggestions.
type FollowUpQuery struct {
ConversationIDs []uint `json:"conversation_ids" validate:"required,min=1"`
AssistantID uint `json:"assistant_id,omitempty"`
}
// FollowUpTask represents a suggested follow-up action.
type FollowUpTask struct {
ConversationID uint `json:"conversation_id"`
Title string `json:"title"`
Description string `json:"description"`
DueDateHint string `json:"due_date_hint,omitempty"` // e.g. "within 24 hours"
Priority string `json:"priority"` // urgent, high, medium, low
AssigneeHint string `json:"assignee_hint,omitempty"` // suggested assignee name
}
// FollowUpResult holds follow-up suggestions for multiple conversations.
type FollowUpResult struct {
FollowUps []FollowUpTask `json:"follow_ups"`
}
// --- Helper: fetch conversation context for LLM ---
func (s *CaptainTaskExtendedService) fetchConversationMessages(ctx context.Context, conversationID uint) (string, error) {
messages, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 50)
if err != nil {
return "", fmt.Errorf("fetch messages for conversation %d: %w", conversationID, err)
}
if len(messages) == 0 {
return "", nil
}
var parts []string
for _, m := range messages {
role := "contact"
if m.SenderType == "user" || m.SenderType == "agent_bot" {
role = "agent"
}
parts = append(parts, fmt.Sprintf("[%s]: %s", role, m.Content))
}
return strings.Join(parts, "\n"), nil
}
func (s *CaptainTaskExtendedService) getPreferencePromptSuffix(ctx context.Context, accountID uint) string {
pref, err := s.preferenceRepo.GetByAccountID(ctx, accountID)
if err != nil || pref == nil {
return ""
}
return pref.CustomPromptSuffix
}
func (s *CaptainTaskExtendedService) buildAssistantContext(ctx context.Context, assistantID uint) string {
if assistantID == 0 {
return ""
}
assistant, err := s.assistantRepo.GetByID(ctx, assistantID)
if err != nil || assistant == nil {
return ""
}
cfg, _ := assistant.GetConfig()
if cfg != nil && cfg.Instructions != "" {
return "\nAssistant instructions: " + cfg.Instructions
}
return ""
}
// --- Label Suggestion (GET) ---
// Reference: Chatwoot Captain::ConversationInsightController#suggest_labels
func (s *CaptainTaskExtendedService) SuggestLabels(ctx context.Context, accountID uint, query *LabelSuggestionQuery) (*LabelSuggestionResult, error) {
// Validate request
if err := pkgvalidator.ValidateStruct(query); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Verify all conversation IDs belong to this account
for _, convID := range query.ConversationIDs {
conv, err := s.conversationRepo.FindByID(ctx, convID)
if err != nil || conv == nil {
return nil, fmt.Errorf("conversation %d not found", convID)
}
if conv.AccountID != accountID {
return nil, fmt.Errorf("conversation %d does not belong to account %d", convID, accountID)
}
}
promptSuffix := s.getPreferencePromptSuffix(ctx, accountID)
assistantCtx := s.buildAssistantContext(ctx, query.AssistantID)
var suggestions []ConversationLabelSuggestion
for _, convID := range query.ConversationIDs {
msgs, err := s.fetchConversationMessages(ctx, convID)
if err != nil {
applogger.L().Warnf("Skip label suggestion for conversation %d: %v", convID, err)
continue
}
if msgs == "" {
continue
}
systemPrompt := `You are a conversation categorization AI. Analyze the conversation and suggest:
1. Appropriate labels/tags (3-5 concise labels that categorize the conversation topic, type, and urgency)
2. Priority level: urgent, high, medium, or low
3. A brief reason explaining why these labels and priority were chosen
Respond in JSON format:
{
"labels": ["label1", "label2", ...],
"priority": "medium",
"reason": "brief explanation"
}` + assistantCtx + promptSuffix
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "",
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: msgs},
},
Temperature: 0.2,
MaxTokens: 512,
})
if err != nil {
applogger.L().Warnf("LLM label suggestion failed for conversation %d: %v", convID, err)
continue
}
if len(llmResp.Choices) == 0 {
continue
}
content := llmResp.Choices[0].Message.Content
var parsed struct {
Labels []string `json:"labels"`
Priority string `json:"priority"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
// Fallback: try to extract from plain text
parsed.Labels = []string{"uncategorized"}
parsed.Priority = "medium"
parsed.Reason = content
}
suggestions = append(suggestions, ConversationLabelSuggestion{
ConversationID: convID,
Labels: parsed.Labels,
Priority: parsed.Priority,
Reason: parsed.Reason,
})
}
return &LabelSuggestionResult{Suggestions: suggestions}, nil
}
// --- Follow Up (GET) ---
// Reference: Chatwoot Captain::ConversationInsightController + M12 PRD §Follow Up Suggestions
func (s *CaptainTaskExtendedService) SuggestFollowUp(ctx context.Context, accountID uint, query *FollowUpQuery) (*FollowUpResult, error) {
// Validate request
if err := pkgvalidator.ValidateStruct(query); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Verify all conversation IDs belong to this account
for _, convID := range query.ConversationIDs {
conv, err := s.conversationRepo.FindByID(ctx, convID)
if err != nil || conv == nil {
return nil, fmt.Errorf("conversation %d not found", convID)
}
if conv.AccountID != accountID {
return nil, fmt.Errorf("conversation %d does not belong to account %d", convID, accountID)
}
}
promptSuffix := s.getPreferencePromptSuffix(ctx, accountID)
assistantCtx := s.buildAssistantContext(ctx, query.AssistantID)
var followUps []FollowUpTask
for _, convID := range query.ConversationIDs {
msgs, err := s.fetchConversationMessages(ctx, convID)
if err != nil {
applogger.L().Warnf("Skip follow-up suggestion for conversation %d: %v", convID, err)
continue
}
if msgs == "" {
continue
}
conversation, err := s.conversationRepo.FindByID(ctx, convID)
if err != nil {
applogger.L().Warnf("Skip follow-up for conversation %d: %v", convID, err)
continue
}
statusHint := ""
if conversation != nil {
statusHint = fmt.Sprintf("\nConversation status: %s", conversation.Status)
}
systemPrompt := `You are a customer support follow-up AI. Analyze the conversation and suggest:
1. A follow-up task title (concise, actionable)
2. A description of what needs to be done next
3. A due date hint (e.g., "within 24 hours", "within 3 business days")
4. Priority level: urgent, high, medium, or low
5. If applicable, suggest who should handle this follow-up
Respond in JSON format:
{
"title": "Follow up with customer about shipping",
"description": "Customer asked about delivery status, need to check tracking number",
"due_date_hint": "within 24 hours",
"priority": "high",
"assignee_hint": "shipping team"
}` + statusHint + assistantCtx + promptSuffix
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "",
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: msgs},
},
Temperature: 0.2,
MaxTokens: 512,
})
if err != nil {
applogger.L().Warnf("LLM follow-up suggestion failed for conversation %d: %v", convID, err)
continue
}
if len(llmResp.Choices) == 0 {
continue
}
content := llmResp.Choices[0].Message.Content
var parsed struct {
Title string `json:"title"`
Description string `json:"description"`
DueDateHint string `json:"due_date_hint"`
Priority string `json:"priority"`
AssigneeHint string `json:"assignee_hint"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
parsed.Title = "Review conversation"
parsed.Description = content
parsed.Priority = "medium"
}
followUps = append(followUps, FollowUpTask{
ConversationID: convID,
Title: parsed.Title,
Description: parsed.Description,
DueDateHint: parsed.DueDateHint,
Priority: parsed.Priority,
AssigneeHint: parsed.AssigneeHint,
})
}
return &FollowUpResult{FollowUps: followUps}, nil
}