750 lines
24 KiB
Go
750 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/pgvector/pgvector-go"
|
|
|
|
"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"
|
|
)
|
|
|
|
// CaptainTaskService implements standalone AI task endpoints for Captain.
|
|
// Reference: Chatwoot enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
|
|
// These tasks (reply_suggestion, summarize, rewrite) are independent of
|
|
// CopilotThread flow — they provide one-shot AI suggestions for agents.
|
|
type CaptainTaskService struct {
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
responseRepo *repository.CaptainAssistantResponseRepo
|
|
customToolRepo *repository.CaptainCustomToolRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
suggestionRepo *repository.CopilotSuggestionRepo
|
|
llmProvider llm.Provider
|
|
promptBuilder *SystemPromptBuilder
|
|
copilotContextSvc *CopilotContextService // M12: context enrichment for richer prompts
|
|
}
|
|
|
|
// NewCaptainTaskService creates a new CaptainTaskService.
|
|
func NewCaptainTaskService(
|
|
assistantRepo *repository.CaptainAssistantRepo,
|
|
responseRepo *repository.CaptainAssistantResponseRepo,
|
|
customToolRepo *repository.CaptainCustomToolRepo,
|
|
conversationRepo *repository.ConversationRepo,
|
|
messageRepo *repository.MessageRepo,
|
|
llmProvider llm.Provider,
|
|
copilotContextSvc *CopilotContextService,
|
|
suggestionRepos ...*repository.CopilotSuggestionRepo,
|
|
) *CaptainTaskService {
|
|
var suggestionRepo *repository.CopilotSuggestionRepo
|
|
if len(suggestionRepos) > 0 {
|
|
suggestionRepo = suggestionRepos[0]
|
|
}
|
|
return &CaptainTaskService{
|
|
assistantRepo: assistantRepo,
|
|
responseRepo: responseRepo,
|
|
customToolRepo: customToolRepo,
|
|
conversationRepo: conversationRepo,
|
|
messageRepo: messageRepo,
|
|
suggestionRepo: suggestionRepo,
|
|
llmProvider: llmProvider,
|
|
promptBuilder: NewSystemPromptBuilder(),
|
|
copilotContextSvc: copilotContextSvc,
|
|
}
|
|
}
|
|
|
|
// --- Task Request/Response DTOs ---
|
|
|
|
// TaskReplySuggestionRequest is the DTO for reply_suggestion task.
|
|
type TaskReplySuggestionRequest struct {
|
|
AssistantID uint `json:"assistant_id"`
|
|
ConversationID uint `json:"conversation_id"`
|
|
ConversationDisplayID uint `json:"conversation_display_id"`
|
|
Tone string `json:"tone"` // legacy local compatibility
|
|
}
|
|
|
|
// TaskSummarizeRequest is the DTO for summarize task.
|
|
type TaskSummarizeRequest struct {
|
|
ConversationID uint `json:"conversation_id"`
|
|
ConversationDisplayID uint `json:"conversation_display_id"`
|
|
Language string `json:"language"` // legacy local compatibility
|
|
}
|
|
|
|
// TaskRewriteRequest is the DTO for rewrite task.
|
|
type TaskRewriteRequest struct {
|
|
Content string `json:"content" validate:"required"`
|
|
Operation string `json:"operation"`
|
|
ConversationDisplayID uint `json:"conversation_display_id"`
|
|
Tone string `json:"tone"` // legacy local compatibility
|
|
Language string `json:"language"` // legacy local compatibility
|
|
AssistantID uint `json:"assistant_id"` // legacy local compatibility
|
|
}
|
|
|
|
// TaskReplySuggestionResult holds the reply suggestion result.
|
|
type TaskReplySuggestionResult struct {
|
|
Message string `json:"message"`
|
|
FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"`
|
|
Suggestions []string `json:"suggestions"`
|
|
Source string `json:"source"` // "llm", "rag+llm"
|
|
}
|
|
|
|
// TaskSummarizeResult holds the conversation summary result.
|
|
type TaskSummarizeResult struct {
|
|
Message string `json:"message"`
|
|
FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// TaskRewriteResult holds the rewritten content result.
|
|
type TaskRewriteResult struct {
|
|
Message string `json:"message"`
|
|
FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"`
|
|
RewrittenContent string `json:"rewritten_content"`
|
|
Tone string `json:"tone"`
|
|
}
|
|
|
|
type CaptainTaskError struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
func (e *CaptainTaskError) Error() string { return e.Message }
|
|
|
|
func taskError(status int, message string) error {
|
|
return &CaptainTaskError{Status: status, Message: message}
|
|
}
|
|
|
|
func CaptainTaskErrorStatus(err error) (int, string, bool) {
|
|
if err == nil {
|
|
return 0, "", false
|
|
}
|
|
if taskErr, ok := err.(*CaptainTaskError); ok {
|
|
return taskErr.Status, taskErr.Message, true
|
|
}
|
|
return 0, "", false
|
|
}
|
|
|
|
// --- Reply Suggestion Task ---
|
|
// Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService
|
|
|
|
// ReplySuggestion generates AI-powered reply suggestions for a conversation.
|
|
// It combines RAG (document-based similarity search) with LLM generation:
|
|
// 1. Fetch recent conversation messages as context
|
|
// 2. Generate embedding for the conversation context
|
|
// 3. Search similar FAQ responses from the assistant documents
|
|
// 4. Build prompt with conversation context + relevant FAQ answers
|
|
// 5. Call LLM to generate reply suggestions
|
|
func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest) (*TaskReplySuggestionResult, error) {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return nil, taskError(422, "Captain is disabled")
|
|
}
|
|
|
|
var assistantCfg *model.CaptainAssistant
|
|
var err error
|
|
if req.AssistantID > 0 {
|
|
assistantCfg, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
|
|
if err != nil {
|
|
return nil, taskError(422, "assistant not found")
|
|
}
|
|
} else {
|
|
assistantCfg = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"}
|
|
assistantCfg.Config = []byte(`{}`)
|
|
}
|
|
|
|
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
|
|
if err != nil {
|
|
return nil, taskError(422, "Conversation not found")
|
|
}
|
|
|
|
messages, err := s.fetchConversationContext(ctx, conversation.ID)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestion: conversation context fetch failed: %v, proceeding without context", err)
|
|
messages = ""
|
|
}
|
|
|
|
// Search relevant documents via embedding similarity (RAG)
|
|
ragContext, source := "", "llm"
|
|
if req.AssistantID > 0 {
|
|
docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestion: document search failed: %v, proceeding without RAG", err)
|
|
} else if docContext != "" {
|
|
ragContext = docContext
|
|
source = "rag+llm"
|
|
}
|
|
}
|
|
|
|
// Build system prompt using prompt builder
|
|
cfg, _ := assistantCfg.GetConfig()
|
|
systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistantCfg, cfg, req.Tone, ragContext)
|
|
|
|
// Build messages for LLM
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: messages},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Model: cfg.Model,
|
|
Messages: chatMessages,
|
|
Temperature: 0.7,
|
|
MaxTokens: 1024,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("ReplySuggestion LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
// Parse suggestions from LLM response
|
|
suggestions := parseSuggestions(resp.Choices[0].Message.Content)
|
|
message := strings.Join(suggestions, "\n")
|
|
if message == "" {
|
|
message = resp.Choices[0].Message.Content
|
|
}
|
|
s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeReply)
|
|
|
|
return &TaskReplySuggestionResult{
|
|
Message: message,
|
|
FollowUpContext: s.buildFollowUpContext("reply_suggestion", messages, message, conversation),
|
|
Suggestions: suggestions,
|
|
Source: source,
|
|
}, nil
|
|
}
|
|
|
|
// --- Summarize Task ---
|
|
// Reference: Chatwoot Captain::Copilot::ChatService summarize action
|
|
|
|
// Summarize generates a concise summary of a conversation.
|
|
func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req *TaskSummarizeRequest) (*TaskSummarizeResult, error) {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return nil, taskError(422, "Captain is disabled")
|
|
}
|
|
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
|
|
if err != nil {
|
|
return nil, taskError(422, "Conversation not found")
|
|
}
|
|
// Fetch conversation messages
|
|
messages, err := s.fetchConversationContext(ctx, conversation.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
|
|
if messages == "" {
|
|
return nil, taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID))
|
|
}
|
|
|
|
// Determine output language
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
// Build summarization prompt using prompt builder
|
|
systemPrompt := s.promptBuilder.BuildSummarizePrompt(language)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: messages},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.3,
|
|
MaxTokens: 512,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("Summarize LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm summarization failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
message := resp.Choices[0].Message.Content
|
|
s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSummary)
|
|
|
|
return &TaskSummarizeResult{
|
|
Message: message,
|
|
FollowUpContext: s.buildFollowUpContext("summarize", messages, message, conversation),
|
|
Summary: message,
|
|
}, nil
|
|
}
|
|
|
|
// --- Rewrite Task ---
|
|
// Reference: Chatwoot Captain::TasksController rewrite action
|
|
|
|
// Rewrite rewrites a draft message to improve tone, clarity, or language.
|
|
func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *TaskRewriteRequest) (*TaskRewriteResult, error) {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return nil, taskError(422, "Captain is disabled")
|
|
}
|
|
operation := req.Operation
|
|
if operation == "" {
|
|
operation = req.Tone
|
|
}
|
|
if operation == "" {
|
|
operation = "professional"
|
|
}
|
|
if !isAllowedRewriteOperation(operation) {
|
|
return nil, taskError(422, "Invalid operation: "+operation)
|
|
}
|
|
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
styleGuidelines := ""
|
|
var conversation *model.Conversation
|
|
conversationContext := ""
|
|
if req.ConversationDisplayID > 0 {
|
|
resolved, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0)
|
|
if err != nil {
|
|
return nil, taskError(422, "Conversation not found")
|
|
}
|
|
conversation = resolved
|
|
if operation == "improve" {
|
|
conversationContext, _ = s.fetchConversationContext(ctx, conversation.ID)
|
|
}
|
|
}
|
|
|
|
if req.AssistantID > 0 {
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
|
|
if err == nil {
|
|
cfg, _ := assistant.GetConfig()
|
|
if cfg.Instructions != "" {
|
|
styleGuidelines = fmt.Sprintf("\nStyle guidelines from your assistant config: %s", cfg.Instructions)
|
|
}
|
|
if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" {
|
|
styleGuidelines += fmt.Sprintf("\nResponse guidelines: %s", string(assistant.ResponseGuidelines))
|
|
}
|
|
}
|
|
}
|
|
if conversationContext != "" {
|
|
styleGuidelines += "\nConversation context: " + conversationContext
|
|
}
|
|
|
|
// Build rewrite prompt using prompt builder
|
|
systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: req.Content},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.5,
|
|
MaxTokens: 512,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("Rewrite LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm rewrite failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
message := resp.Choices[0].Message.Content
|
|
if conversation != nil {
|
|
s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSuggestion)
|
|
}
|
|
|
|
return &TaskRewriteResult{
|
|
Message: message,
|
|
FollowUpContext: s.buildFollowUpContext(operation, req.Content, message, conversation),
|
|
RewrittenContent: message,
|
|
Tone: operation,
|
|
}, nil
|
|
}
|
|
|
|
// --- Helper Methods ---
|
|
|
|
// fetchConversationContext retrieves recent messages from a conversation and formats them.
|
|
func (s *CaptainTaskService) fetchConversationContext(ctx context.Context, conversationID uint) (string, error) {
|
|
msgs, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 20)
|
|
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
|
|
}
|
|
|
|
func (s *CaptainTaskService) resolveTaskConversation(ctx context.Context, accountID, displayID, legacyID uint) (*model.Conversation, error) {
|
|
if displayID > 0 {
|
|
return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID)
|
|
}
|
|
if legacyID > 0 {
|
|
return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, legacyID)
|
|
}
|
|
return nil, fmt.Errorf("conversation_display_id required")
|
|
}
|
|
|
|
func isAllowedRewriteOperation(operation string) bool {
|
|
switch operation {
|
|
case "fix_spelling_grammar", "improve", "casual", "professional", "friendly", "confident", "straightforward":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *CaptainTaskService) buildFollowUpContext(eventName, originalContext, lastResponse string, conversation *model.Conversation) map[string]interface{} {
|
|
ctx := map[string]interface{}{
|
|
"event_name": eventName,
|
|
"original_context": originalContext,
|
|
"last_response": lastResponse,
|
|
"conversation_history": []map[string]string{},
|
|
}
|
|
if conversation != nil {
|
|
ctx["channel_type"] = conversation.ChannelType
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func (s *CaptainTaskService) persistTaskSuggestion(ctx context.Context, accountID, conversationID uint, content string, suggestionType model.CopilotSuggestionType) {
|
|
if s.suggestionRepo == nil || strings.TrimSpace(content) == "" || conversationID == 0 {
|
|
return
|
|
}
|
|
msg := &model.CopilotSuggestionMessage{
|
|
AccountID: accountID,
|
|
ConversationID: conversationID,
|
|
Content: content,
|
|
SuggestionType: suggestionType,
|
|
Status: model.CopilotSuggestionStatusPending,
|
|
}
|
|
if err := s.suggestionRepo.Create(ctx, msg); err != nil {
|
|
applogger.L().Warnf("Captain task suggestion persistence failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// searchDocumentation performs embedding-based similarity search on assistant responses.
|
|
// Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService
|
|
func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantID uint, query string) (string, error) {
|
|
if query == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Generate embedding for the query
|
|
embResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
|
Input: []string{query},
|
|
Model: "",
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create embedding: %w", err)
|
|
}
|
|
|
|
if len(embResp.Data) == 0 {
|
|
return "", fmt.Errorf("no embedding generated")
|
|
}
|
|
|
|
// Convert float64 embedding to float32 for pgvector
|
|
float64Emb := embResp.Data[0].Embedding
|
|
float32Emb := make([]float32, len(float64Emb))
|
|
for i, v := range float64Emb {
|
|
float32Emb[i] = float32(v)
|
|
}
|
|
embedding := pgvector.NewVector(float32Emb)
|
|
|
|
// Search similar responses
|
|
results, err := s.responseRepo.SimilaritySearch(ctx, assistantID, embedding, 5)
|
|
if err != nil {
|
|
return "", fmt.Errorf("similarity search: %w", err)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// Format relevant FAQ answers as context
|
|
var builder strings.Builder
|
|
builder.WriteString("Relevant FAQ answers from documentation:\n")
|
|
for i, r := range results {
|
|
builder.WriteString(fmt.Sprintf("%d. Q: %s\n A: %s\n", i+1, r.Question, r.Answer))
|
|
}
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// parseSuggestions splits LLM output into individual suggestions.
|
|
// The LLM may return numbered lines, bullet points, or comma-separated items.
|
|
func parseSuggestions(content string) []string {
|
|
lines := strings.Split(content, "\n")
|
|
var suggestions []string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Strip leading numbers/bullets
|
|
for _, prefix := range []string{"1.", "2.", "3.", "4.", "5.", "1)", "2)", "3)", "4)", "5)", "- ", "* "} {
|
|
if strings.HasPrefix(line, prefix) {
|
|
line = strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
break
|
|
}
|
|
}
|
|
if line != "" {
|
|
suggestions = append(suggestions, line)
|
|
}
|
|
}
|
|
|
|
// If only one block, try comma separation
|
|
if len(suggestions) <= 1 {
|
|
parts := strings.Split(content, ",")
|
|
suggestions = nil
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
suggestions = append(suggestions, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(suggestions) == 0 {
|
|
suggestions = []string{content}
|
|
}
|
|
|
|
return suggestions
|
|
}
|
|
|
|
// --- Streaming Task Methods (M12: SSE streaming support) ---
|
|
// These methods build the same prompts as the non-streaming variants but use
|
|
// ChatCompletionStream to deliver tokens incrementally via an onChunk callback.
|
|
|
|
// ReplySuggestionStream streams reply suggestions via an onChunk callback.
|
|
// The callback receives StreamChunk events; the caller (handler) writes SSE events.
|
|
func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest, onChunk func(llm.StreamChunk) error) error {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return taskError(422, "Captain is disabled")
|
|
}
|
|
|
|
var assistant *model.CaptainAssistant
|
|
var err error
|
|
if req.AssistantID > 0 {
|
|
assistant, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
|
|
if err != nil {
|
|
return taskError(422, "assistant not found")
|
|
}
|
|
} else {
|
|
assistant = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"}
|
|
assistant.Config = []byte(`{}`)
|
|
}
|
|
|
|
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
|
|
if err != nil {
|
|
return taskError(422, "Conversation not found")
|
|
}
|
|
|
|
messages, err := s.fetchConversationContext(ctx, conversation.ID)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestionStream: conversation context fetch failed: %v, proceeding without context", err)
|
|
messages = ""
|
|
}
|
|
|
|
// Enrich context with CopilotContextService (M12)
|
|
if s.copilotContextSvc != nil && messages != "" {
|
|
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestionStream: context enrichment failed: %v", err)
|
|
} else if ctxView != nil {
|
|
var enriched strings.Builder
|
|
if ctxView.ContactName != "" {
|
|
enriched.WriteString(fmt.Sprintf("Contact: %s (%s)\n", ctxView.ContactName, ctxView.ContactEmail))
|
|
}
|
|
for _, m := range ctxView.Messages {
|
|
enriched.WriteString(fmt.Sprintf("[%s]: %s\n", m.Role, m.Content))
|
|
}
|
|
if enriched.Len() > 0 {
|
|
messages = enriched.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
ragContext := ""
|
|
if req.AssistantID > 0 {
|
|
docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestionStream: document search failed: %v, proceeding without RAG", err)
|
|
} else if docContext != "" {
|
|
ragContext = docContext
|
|
}
|
|
}
|
|
|
|
cfg, _ := assistant.GetConfig()
|
|
systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistant, cfg, req.Tone, ragContext)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: messages},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.7,
|
|
MaxTokens: 1024,
|
|
Stream: true,
|
|
}
|
|
|
|
return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk)
|
|
}
|
|
|
|
// SummarizeStream streams a conversation summary via an onChunk callback.
|
|
func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint, req *TaskSummarizeRequest, onChunk func(llm.StreamChunk) error) error {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return taskError(422, "Captain is disabled")
|
|
}
|
|
|
|
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
|
|
if err != nil {
|
|
return taskError(422, "Conversation not found")
|
|
}
|
|
|
|
conversationContext, err := s.fetchConversationContext(ctx, conversation.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
if conversationContext == "" {
|
|
return taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID))
|
|
}
|
|
|
|
// Enrich context with CopilotContextService (M12)
|
|
if s.copilotContextSvc != nil && conversationContext != "" {
|
|
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
|
|
if err != nil {
|
|
applogger.L().Warnf("SummarizeStream: context enrichment failed: %v", err)
|
|
} else if ctxView != nil {
|
|
var enriched strings.Builder
|
|
if ctxView.ContactName != "" {
|
|
enriched.WriteString(fmt.Sprintf("Contact: %s (%s)\n", ctxView.ContactName, ctxView.ContactEmail))
|
|
}
|
|
for _, m := range ctxView.Messages {
|
|
enriched.WriteString(fmt.Sprintf("[%s]: %s\n", m.Role, m.Content))
|
|
}
|
|
if enriched.Len() > 0 {
|
|
conversationContext = enriched.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
systemPrompt := s.promptBuilder.BuildSummarizePrompt(language)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: conversationContext},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.3,
|
|
MaxTokens: 512,
|
|
Stream: true,
|
|
}
|
|
|
|
return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk)
|
|
}
|
|
|
|
// RewriteStream streams a rewritten message via an onChunk callback.
|
|
func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint, req *TaskRewriteRequest, onChunk func(llm.StreamChunk) error) error {
|
|
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
|
if s.llmProvider == nil {
|
|
return taskError(422, "Captain is disabled")
|
|
}
|
|
|
|
operation := req.Operation
|
|
if operation == "" {
|
|
operation = req.Tone
|
|
}
|
|
if operation == "" {
|
|
operation = "professional"
|
|
}
|
|
if !isAllowedRewriteOperation(operation) {
|
|
return taskError(422, "Invalid operation: "+operation)
|
|
}
|
|
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
styleGuidelines := ""
|
|
if req.ConversationDisplayID > 0 {
|
|
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0)
|
|
if err != nil {
|
|
return taskError(422, "Conversation not found")
|
|
}
|
|
if operation == "improve" {
|
|
conversationContext, _ := s.fetchConversationContext(ctx, conversation.ID)
|
|
if conversationContext != "" {
|
|
styleGuidelines += "\nConversation context: " + conversationContext
|
|
}
|
|
}
|
|
}
|
|
if req.AssistantID > 0 {
|
|
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
|
|
if err == nil {
|
|
cfg, _ := assistant.GetConfig()
|
|
if cfg.Instructions != "" {
|
|
styleGuidelines = fmt.Sprintf("\nStyle guidelines from your assistant config: %s", cfg.Instructions)
|
|
}
|
|
if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" {
|
|
styleGuidelines += fmt.Sprintf("\nResponse guidelines: %s", string(assistant.ResponseGuidelines))
|
|
}
|
|
}
|
|
}
|
|
|
|
systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: req.Content},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.5,
|
|
MaxTokens: 512,
|
|
Stream: true,
|
|
}
|
|
|
|
return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk)
|
|
}
|