- token_estimator.go (new): TokenEstimator with ~4 chars/token heuristic, EstimateText/EstimateMessages methods, TruncateMessages sliding window that drops oldest messages to fit token budget, BuildContextWindow entry point that converts conversation messages to LLM format with token-budgeted truncation (default 4096 tokens) - copilot_context_service.go: fetch up to 100 messages (was 20), then apply BuildContextWindow truncation to fit within 4096 token budget; log how many messages were dropped Verified: go build + go vet + go test all pass Semantic search route reaches handler (times out on LLM call without API key, confirming route + service wiring is correct)
322 lines
11 KiB
Go
322 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// CopilotContextService provides conversation context injection for Copilot LLM calls.
|
|
// Reference: Chatwoot Captain::Copilot::ChatService — current_viewing_history + account context
|
|
//
|
|
// This service retrieves the conversation the agent is currently viewing,
|
|
// formats it as LLM context, and enriches Copilot prompts with real data.
|
|
type CopilotContextService struct {
|
|
messageRepo *repository.MessageRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
contactRepo *repository.ContactRepo
|
|
llmProvider llm.Provider
|
|
}
|
|
|
|
// NewCopilotContextService creates a new CopilotContextService.
|
|
func NewCopilotContextService(
|
|
messageRepo *repository.MessageRepo,
|
|
conversationRepo *repository.ConversationRepo,
|
|
contactRepo *repository.ContactRepo,
|
|
llmProvider llm.Provider,
|
|
) *CopilotContextService {
|
|
return &CopilotContextService{
|
|
messageRepo: messageRepo,
|
|
conversationRepo: conversationRepo,
|
|
contactRepo: contactRepo,
|
|
llmProvider: llmProvider,
|
|
}
|
|
}
|
|
|
|
// --- Conversation Context Building ---
|
|
// Reference: Chatwoot Captain::Copilot::ChatService#current_viewing_history
|
|
|
|
// ConversationContext holds formatted context from the currently viewed conversation.
|
|
type ConversationContext struct {
|
|
ConversationID uint `json:"conversation_id"`
|
|
ContactName string `json:"contact_name,omitempty"`
|
|
ContactEmail string `json:"contact_email,omitempty"`
|
|
Messages []ContextMessage `json:"messages"`
|
|
Summary string `json:"summary,omitempty"`
|
|
}
|
|
|
|
// ContextMessage is a simplified message format for LLM context injection.
|
|
type ContextMessage struct {
|
|
Role string `json:"role"` // "customer" or "agent"
|
|
Content string `json:"content"`
|
|
Timestamp string `json:"timestamp,omitempty"`
|
|
}
|
|
|
|
// GetCurrentViewingContext retrieves and formats the conversation context
|
|
// that the agent is currently viewing, to inject into Copilot LLM calls.
|
|
func (s *CopilotContextService) GetCurrentViewingContext(ctx context.Context, accountID, conversationID uint) (*ConversationContext, error) {
|
|
// Get conversation messages (fetch up to 100, then truncate by token budget)
|
|
messages, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 100)
|
|
if err != nil {
|
|
applogger.L().Errorf("GetCurrentViewingContext FindByConversation: %v", err)
|
|
return nil, fmt.Errorf("retrieve conversation messages: %w", err)
|
|
}
|
|
|
|
// Get conversation for contact info
|
|
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
|
|
if err != nil {
|
|
applogger.L().Errorf("GetCurrentViewingContext GetByID: %v", err)
|
|
return nil, fmt.Errorf("retrieve conversation: %w", err)
|
|
}
|
|
|
|
// Build context
|
|
context := &ConversationContext{
|
|
ConversationID: conversationID,
|
|
}
|
|
|
|
// Add contact details if available
|
|
if conversation.ContactID != 0 {
|
|
contact, err := s.contactRepo.FindByID(ctx, conversation.ContactID)
|
|
if err == nil && contact != nil {
|
|
context.ContactName = contact.Name
|
|
context.ContactEmail = contact.Email
|
|
}
|
|
}
|
|
|
|
// Format messages for LLM context with token-budgeted truncation
|
|
// (keeps most recent messages, drops older ones to fit token budget)
|
|
convMsgs := make([]ConversationMessage, 0, len(messages))
|
|
for _, m := range messages {
|
|
// Skip activity/template messages
|
|
if m.MessageType == "activity" || m.ContentType != "text" {
|
|
continue
|
|
}
|
|
convMsgs = append(convMsgs, ConversationMessage{
|
|
Content: m.Content,
|
|
MessageType: m.MessageType,
|
|
})
|
|
}
|
|
|
|
truncated, dropped := BuildContextWindow(convMsgs, 4096)
|
|
if dropped > 0 {
|
|
applogger.L().Infof("GetCurrentViewingContext: truncated %d older messages to fit token budget", dropped)
|
|
}
|
|
|
|
for _, msg := range truncated {
|
|
role := "customer"
|
|
if msg.Role == "assistant" {
|
|
role = "agent"
|
|
}
|
|
context.Messages = append(context.Messages, ContextMessage{
|
|
Role: role,
|
|
Content: msg.Content,
|
|
})
|
|
}
|
|
|
|
return context, nil
|
|
}
|
|
|
|
// BuildCopilotSystemPrompt constructs the system prompt for Copilot chat.
|
|
// Incorporates assistant configuration, product context, and available tools.
|
|
// Reference: Chatwoot Captain::Copilot::ChatService system_message building
|
|
func BuildCopilotSystemPrompt(assistant *model.CaptainAssistant, assistantConfig *model.AssistantConfig, context *ConversationContext) string {
|
|
var parts []string
|
|
|
|
// Base identity
|
|
parts = append(parts, "You are an AI copilot assistant helping a customer support agent.")
|
|
|
|
// Product context from assistant config
|
|
if assistantConfig != nil && assistantConfig.ProductName != "" {
|
|
parts = append(parts, fmt.Sprintf("The agent is supporting customers of the product: %s.", assistantConfig.ProductName))
|
|
}
|
|
|
|
// Assistant name and description
|
|
if assistant != nil {
|
|
parts = append(parts, fmt.Sprintf("Your name is %s.", assistant.Name))
|
|
if assistant.Description != "" {
|
|
parts = append(parts, fmt.Sprintf("Your role: %s.", assistant.Description))
|
|
}
|
|
}
|
|
|
|
// Response guidelines
|
|
if assistant != nil && assistant.ResponseGuidelines != nil {
|
|
guidelines, _ := assistant.GetResponseGuidelines()
|
|
if guidelines != "" {
|
|
parts = append(parts, "Response guidelines:\n" + guidelines)
|
|
}
|
|
}
|
|
|
|
// Feature flags context
|
|
if assistantConfig != nil {
|
|
if assistantConfig.FeatureFAQ {
|
|
parts = append(parts, "You have access to FAQ knowledge base search. Use it to find relevant answers.")
|
|
}
|
|
if assistantConfig.FeatureMemory {
|
|
parts = append(parts, "You can remember previous interactions with this customer.")
|
|
}
|
|
if assistantConfig.FeatureContactAttributes {
|
|
parts = append(parts, "You can access customer contact attributes and history.")
|
|
}
|
|
}
|
|
|
|
// Conversation context injection
|
|
if context != nil && len(context.Messages) > 0 {
|
|
parts = append(parts, "\nCurrently viewed conversation context:")
|
|
if context.ContactName != "" {
|
|
parts = append(parts, fmt.Sprintf("Customer: %s", context.ContactName))
|
|
if context.ContactEmail != "" {
|
|
parts = append(parts, fmt.Sprintf("Email: %s", context.ContactEmail))
|
|
}
|
|
}
|
|
parts = append(parts, "Conversation messages:")
|
|
for _, msg := range context.Messages {
|
|
parts = append(parts, fmt.Sprintf("[%s] %s: %s", msg.Timestamp, msg.Role, msg.Content))
|
|
}
|
|
}
|
|
|
|
// Behavioral guidelines
|
|
parts = append(parts, "\nBehavior guidelines:")
|
|
parts = append(parts, "- Be concise and actionable in your suggestions.")
|
|
parts = append(parts, "- Prioritize accuracy over speed; cite sources when possible.")
|
|
parts = append(parts, "- When suggesting replies, tailor them to the customer's tone and urgency.")
|
|
parts = append(parts, "- If you're unsure, say so rather than guessing.")
|
|
parts = append(parts, "- Always respond in the same language as the user's message.")
|
|
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
// BuildCopilotToolDefinitions creates tool definitions for Copilot LLM function calling.
|
|
// Reference: Chatwoot Captain::Copilot tools — GetConversation, SearchConversations,
|
|
// GetContact, SearchArticles, SearchContacts, SearchLinearIssues, SearchDocumentation
|
|
func BuildCopilotToolDefinitions() []llm.ToolDefinition {
|
|
return []llm.ToolDefinition{
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "search_documentation",
|
|
Description: "Search the knowledge base / FAQ documentation for relevant information to answer the customer's question.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "The search query to find relevant documentation.",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "get_conversation",
|
|
Description: "Get details about a specific conversation including messages and metadata.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"conversation_id": map[string]interface{}{
|
|
"type": "integer",
|
|
"description": "The ID of the conversation to retrieve.",
|
|
},
|
|
},
|
|
"required": []string{"conversation_id"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "search_conversations",
|
|
Description: "Search conversations by status, assignee, or content to find related cases.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Search query for conversation content.",
|
|
},
|
|
"status": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Filter by conversation status (open, closed, pending).",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "get_contact",
|
|
Description: "Get details about a specific contact including name, email, and custom attributes.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"contact_id": map[string]interface{}{
|
|
"type": "integer",
|
|
"description": "The ID of the contact to retrieve.",
|
|
},
|
|
},
|
|
"required": []string{"contact_id"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "search_contacts",
|
|
Description: "Search contacts by name, email, or custom attributes.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Search query for contact information.",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "get_article",
|
|
Description: "Get a specific help center article by ID.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"article_id": map[string]interface{}{
|
|
"type": "integer",
|
|
"description": "The ID of the article to retrieve.",
|
|
},
|
|
},
|
|
"required": []string{"article_id"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "search_articles",
|
|
Description: "Search help center articles by title or content.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Search query for article content.",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
} |