304 lines
10 KiB
Go
304 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// ConversationInsightService provides AI-powered conversation analysis features:
|
|
// - Participant analysis (role, sentiment, engagement)
|
|
// - Action items extraction (tasks, deadlines, owners)
|
|
// - Label/priority suggestion (auto-tagging conversations)
|
|
//
|
|
// Reference: Chatwoot Captain::Llm::ConversationInsightService + M12 PRD
|
|
|
|
// --- Request/Response DTOs ---
|
|
|
|
// ParticipantAnalysisRequest is the input for analyzing conversation participants.
|
|
type ParticipantAnalysisRequest struct {
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ParticipantAnalysisResult holds the participant analysis result.
|
|
type ParticipantAnalysisResult struct {
|
|
Participants []ParticipantInfo `json:"participants"`
|
|
Summary string `json:"summary"` // brief overall participant dynamics summary
|
|
}
|
|
|
|
// ActionItemsRequest is the input for extracting action items from a conversation.
|
|
type ActionItemsRequest struct {
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
}
|
|
|
|
// ActionItem holds a single action item extracted from a conversation.
|
|
type ActionItem struct {
|
|
Description string `json:"description"`
|
|
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"
|
|
}
|
|
|
|
// ActionItemsResult holds the extracted action items.
|
|
type ActionItemsResult struct {
|
|
Items []ActionItem `json:"items"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// InsightLabelSuggestionResult holds suggested labels and priority.
|
|
type InsightLabelSuggestionResult struct {
|
|
Labels []string `json:"labels"`
|
|
Priority string `json:"priority"` // "urgent", "high", "medium", "low"
|
|
Reason string `json:"reason"` // brief explanation of the suggestion
|
|
}
|
|
|
|
// ConversationInsightService orchestrates AI-powered conversation analysis.
|
|
type ConversationInsightService struct {
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
llmProvider llm.Provider
|
|
promptBuilder *SystemPromptBuilder
|
|
}
|
|
|
|
// NewConversationInsightService creates a new ConversationInsightService.
|
|
func NewConversationInsightService(
|
|
conversationRepo *repository.ConversationRepo,
|
|
messageRepo *repository.MessageRepo,
|
|
assistantRepo *repository.CaptainAssistantRepo,
|
|
llmProvider llm.Provider,
|
|
) *ConversationInsightService {
|
|
return &ConversationInsightService{
|
|
conversationRepo: conversationRepo,
|
|
messageRepo: messageRepo,
|
|
assistantRepo: assistantRepo,
|
|
llmProvider: llmProvider,
|
|
promptBuilder: NewSystemPromptBuilder(),
|
|
}
|
|
}
|
|
|
|
// --- Participant Analysis ---
|
|
// Reference: M12 PRD §Captain AI — Participant Analysis
|
|
|
|
// AnalyzeParticipants analyzes the participants in a conversation.
|
|
func (s *ConversationInsightService) AnalyzeParticipants(ctx context.Context, accountID uint, req *ParticipantAnalysisRequest) (*ParticipantAnalysisResult, error) {
|
|
// Fetch conversation messages
|
|
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
if contextStr == "" {
|
|
return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID)
|
|
}
|
|
|
|
systemPrompt := `You are a conversation analysis AI. Analyze the participants in the following conversation.
|
|
For each participant, identify:
|
|
1. Their role (customer, agent, manager, etc.)
|
|
2. Their overall sentiment (positive, neutral, negative)
|
|
3. The main topics they discussed (2-3 keywords)
|
|
4. Their engagement level (0-1 score based on message frequency and depth)
|
|
|
|
Also provide a brief summary of the overall participant dynamics.
|
|
|
|
Return your analysis as JSON in this exact format:
|
|
{
|
|
"participants": [
|
|
{"name": "...", "role": "...", "sentiment": "...", "topics": ["..."], "engagement": 0.0}
|
|
],
|
|
"summary": "..."
|
|
}`
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: contextStr},
|
|
},
|
|
Temperature: 0.3,
|
|
MaxTokens: 1024,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("AnalyzeParticipants LLM: %v", err)
|
|
return nil, fmt.Errorf("participant analysis failed: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no LLM response")
|
|
}
|
|
|
|
content := llmResp.Choices[0].Message.Content
|
|
// Try to parse JSON, fallback to text extraction
|
|
var result ParticipantAnalysisResult
|
|
if err := parseJSONResponse(content, &result); err != nil {
|
|
// Fallback: extract structured data from plain text
|
|
result = extractParticipantInfoFromText(content)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// --- Action Items Extraction ---
|
|
// Reference: M12 PRD §Captain AI — Action Items
|
|
|
|
// ExtractActionItems extracts action items from a conversation.
|
|
func (s *ConversationInsightService) ExtractActionItems(ctx context.Context, accountID uint, req *ActionItemsRequest) (*ActionItemsResult, error) {
|
|
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
if contextStr == "" {
|
|
return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID)
|
|
}
|
|
|
|
systemPrompt := `You are an action item extraction AI. Analyze the conversation and extract all actionable items.
|
|
For each action item, identify:
|
|
1. Description: what needs to be done
|
|
2. Owner: who is responsible (if mentioned)
|
|
3. Deadline: any mentioned deadline
|
|
4. Priority: high, medium, or low
|
|
5. Status: pending, in_progress, or completed (if mentioned)
|
|
|
|
Return as JSON array:
|
|
{
|
|
"items": [
|
|
{"description": "...", "owner": "...", "deadline": "...", "priority": "...", "status": "..."}
|
|
]
|
|
}`
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: contextStr},
|
|
},
|
|
Temperature: 0.2,
|
|
MaxTokens: 512,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("ExtractActionItems LLM: %v", err)
|
|
return nil, fmt.Errorf("action item extraction failed: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no LLM response")
|
|
}
|
|
|
|
var result ActionItemsResult
|
|
content := llmResp.Choices[0].Message.Content
|
|
if err := parseJSONResponse(content, &result); err != nil {
|
|
// Fallback: try to extract items from plain text
|
|
result = extractActionItemsFromText(content)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// --- Label/Priority Suggestion ---
|
|
// Reference: M12 PRD §Captain AI — Recommendation Engine
|
|
|
|
// SuggestLabels suggests labels and priority for a conversation.
|
|
func (s *ConversationInsightService) SuggestLabels(ctx context.Context, accountID uint, req *LabelSuggestionRequest) (*LabelSuggestionResult, error) {
|
|
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
if contextStr == "" {
|
|
return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID)
|
|
}
|
|
|
|
// Optionally use assistant guidelines for context-aware suggestions
|
|
var assistantPrompt string
|
|
if req.AssistantID > 0 {
|
|
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
|
|
if err == nil && assistant != nil {
|
|
cfg, _ := assistant.GetConfig()
|
|
assistantPrompt = s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
Return as JSON:
|
|
{
|
|
"labels": ["label1", "label2", ...],
|
|
"priority": "...",
|
|
"reason": "..."
|
|
}`
|
|
|
|
if assistantPrompt != "" {
|
|
systemPrompt = assistantPrompt + "\n\n" + systemPrompt
|
|
}
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: contextStr},
|
|
},
|
|
Temperature: 0.3,
|
|
MaxTokens: 256,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("SuggestLabels LLM: %v", err)
|
|
return nil, fmt.Errorf("label suggestion failed: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no LLM response")
|
|
}
|
|
|
|
var result LabelSuggestionResult
|
|
content := llmResp.Choices[0].Message.Content
|
|
if err := parseJSONResponse(content, &result); err != nil {
|
|
result = extractLabelSuggestionFromText(content)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// --- Helper Methods ---
|
|
|
|
// fetchConversationContext retrieves recent messages and formats them for LLM input.
|
|
func (s *ConversationInsightService) fetchConversationContext(ctx context.Context, conversationID uint) (string, error) {
|
|
msgs, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 50)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch messages: %w", err)
|
|
}
|
|
|
|
if len(msgs) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for _, msg := range msgs {
|
|
sender := "Customer"
|
|
if msg.SenderType == "agent" || msg.SenderType == "user" {
|
|
sender = "Agent"
|
|
}
|
|
builder.WriteString(fmt.Sprintf("[%s]: %s\n", sender, msg.Content))
|
|
}
|
|
|
|
return builder.String(), nil
|
|
} |