Files
gochat/backend/internal/service/copilot_context_service.go
T
rogee 6d8eda28b5 Phase 3.4: Context window optimization — token estimation + sliding window
- 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)
2026-07-08 15:38:08 +08:00

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"},
},
},
},
}
}