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

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
}