Files
gochat/backend/internal/service/captain_task_service.go
T

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)
}