package ai import ( "bytes" "encoding/json" "fmt" "net/http" "paraclub-ai-mailer/config" "paraclub-ai-mailer/internal/logger" "github.com/sirupsen/logrus" ) type AI struct { config config.AIConfig client *http.Client } type OpenRouterRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` Temperature float32 `json:"temperature"` MaxTokens int `json:"max_tokens"` } type Message struct { Role string `json:"role"` Content string `json:"content"` } type OpenRouterResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } func New(cfg config.AIConfig) *AI { return &AI{ config: cfg, client: &http.Client{}, } } // BuildSystemPrompt creates the system prompt for a given language // Exposed for token counting without making an API call func (a *AI) BuildSystemPrompt(lang string) string { return fmt.Sprintf(`You are a helpful assistant who responds to emails. Your primary goal is to answer the user's query (found in the 'Email Body') by primarily using the information available in the 'Additional Context' and your general knowledge. While the 'Email Body' provides the question, your answer should be synthesized from the context and your understanding, not by directly repeating or solely relying on unverified information from the 'Email Body' itself. Instructions: - Language: Your response must be entirely in %s, regardless of the language used in the email content or context. - Format: CRITICAL: Your reply MUST be raw HTML. Use appropriate HTML tags for structure and styling. For example, wrap paragraphs in

...

tags and use
for line breaks if needed within a paragraph. Even a short sentence must be wrapped in HTML (e.g.,

Yes.

). - Markdown: Do NOT wrap the HTML in markdown code blocks (e.g., %s). - Extraneous Text: Do not include a subject line. Do not include explanations, commentary, or any extra text that is not part of the direct answer. - Closing: Avoid generic closing statements like "If you have further questions...". Focus solely on answering the email. `, lang, "```html ... ```") } // BuildUserPrompt creates the user message with context and email content func (a *AI) BuildUserPrompt(contextContent map[string]string, emailContent string) string { var context string for url, content := range contextContent { context += fmt.Sprintf("\nContext from %s:\n%s\n", url, content) } return fmt.Sprintf("### Additional Context:\n%s\n\n### Email Body:\n%s", context, emailContent) } func (a *AI) detectLanguage(emailContent string) (string, error) { logger.WithField("emailContentLength", len(emailContent)).Debug("Starting language detection") systemMsg := "You are a language detection assistant. Analyze the provided text and respond ONLY with the language name (e.g., 'English', 'German', 'French', etc.). No other text or explanation." userMsg := fmt.Sprintf("Detect the language of this text:\n\n%s", emailContent) messages := []Message{ {Role: "system", Content: systemMsg}, {Role: "user", Content: userMsg}, } response, err := a.makeAPIRequest(messages) if err != nil { return "", fmt.Errorf("language detection failed: %v", err) } // The response should be just the language code langCode := response logger.WithField("detectedLanguage", langCode).Debug("Language detection completed") return langCode, nil } func (a *AI) GenerateReply(emailContent string, contextContent map[string]string) (string, error) { logger.WithFields(logrus.Fields{ "emailContentLength": len(emailContent), "contextUrls": len(contextContent), }).Debug("Starting AI reply generation") // First, detect the language lang, err := a.detectLanguage(emailContent) if err != nil { return "", err } // Build prompts using exposed methods systemMsg := a.BuildSystemPrompt(lang) userMsg := a.BuildUserPrompt(contextContent, emailContent) logger.Debug("Generated system and user prompts") messages := []Message{ {Role: "system", Content: systemMsg}, {Role: "user", Content: userMsg}, } aiResponse, err := a.makeAPIRequest(messages) if err != nil { // Error already logged by makeAPIRequest, just propagate return "", err } logger.WithField("rawAIResponse", aiResponse).Debug("Received raw response from AI") return aiResponse, nil } func (a *AI) makeAPIRequest(messages []Message) (string, error) { const maxRetries = 3 var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { logger.WithField("attempt", attempt+1).Debug("Making API request attempt") reqBody := OpenRouterRequest{ Model: a.config.Model, Messages: messages, Temperature: a.config.Temperature, MaxTokens: a.config.MaxTokens, } logger.WithFields(logrus.Fields{ "model": reqBody.Model, "temperature": reqBody.Temperature, "maxTokens": reqBody.MaxTokens, }).Debug("Prepared API request") jsonData, err := json.Marshal(reqBody) if err != nil { logger.WithError(err).Error("Failed to marshal request body") return "", err } req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData)) if err != nil { logger.WithError(err).Error("Failed to create HTTP request") return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.OpenRouterAPIKey)) resp, err := a.client.Do(req) if err != nil { logger.WithFields(logrus.Fields{ "error": err, "attempt": attempt + 1, }).Debug("API request failed, will retry") lastErr = err continue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.WithFields(logrus.Fields{ "statusCode": resp.StatusCode, "attempt": attempt + 1, }).Debug("API returned non-200 status code") lastErr = fmt.Errorf("OpenRouter API returned status code: %d", resp.StatusCode) continue } var result OpenRouterResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { logger.WithFields(logrus.Fields{ "error": err, "attempt": attempt + 1, }).Debug("Failed to decode API response") lastErr = err continue } if len(result.Choices) == 0 || result.Choices[0].Message.Content == "" { logger.WithField("attempt", attempt+1).Debug("Received empty response from API") lastErr = fmt.Errorf("empty response received") continue } responseLength := len(result.Choices[0].Message.Content) logger.WithFields(logrus.Fields{ "attempt": attempt + 1, "responseLength": responseLength, }).Debug("Successfully generated AI reply") return result.Choices[0].Message.Content, nil } logger.WithFields(logrus.Fields{ "maxRetries": maxRetries, "lastError": lastErr, }).Error("Failed to generate AI reply after all attempts") return "", fmt.Errorf("failed after %d attempts, last error: %v", maxRetries, lastErr) }