package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time" ) // TriageConfig holds LLM triage configuration type TriageConfig struct { Enabled bool `yaml:"enabled"` PromptFile string `yaml:"prompt_file"` Provider ProviderConfig `yaml:"provider"` DashboardURL string `yaml:"dashboard_url"` } type ProviderConfig struct { BaseURL string `yaml:"base_url"` APIKey string `yaml:"api_key"` Model string `yaml:"model"` } // TriageResult is the expected LLM response type TriageResult struct { Action string `json:"action"` // junk, pass Reason string `json:"reason"` } // triageLogEntry is a structured log entry for triage decisions type triageLogEntry struct { Ts string `json:"ts"` ID string `json:"id"` From string `json:"from"` Subject string `json:"subject"` Action string `json:"action"` Reason string `json:"reason"` } // triageEmail runs LLM triage on an email message. // Returns the triage result, or nil if triage should be skipped (fall through to webhook). func triageEmail(msg UnifiedMessage, cfg TriageConfig) *TriageResult { // Read prompt file fresh every time promptBytes, err := os.ReadFile(cfg.PromptFile) if err != nil { log.Printf("[triage] Failed to read prompt file %s: %v — falling through to webhook", cfg.PromptFile, err) return nil } systemPrompt := string(promptBytes) // Build user message with structured email data attachmentNames := make([]string, 0, len(msg.Attachments)) for _, a := range msg.Attachments { attachmentNames = append(attachmentNames, a.Name) } userMsg := fmt.Sprintf(`From: %s (%s) To: %s Account: %s Subject: %s Date: %s Has Attachments: %v Attachment Names: %s Body: %s`, msg.From, msg.FromName, msg.To, msg.Source, msg.Subject, msg.Timestamp.Format(time.RFC1123), len(msg.Attachments) > 0, strings.Join(attachmentNames, ", "), msg.Body, ) // Truncate body to avoid huge payloads if len(userMsg) > 12000 { userMsg = userMsg[:12000] + "\n...[truncated]" } // Call LLM result, err := callLLM(cfg.Provider, systemPrompt, userMsg) if err != nil { log.Printf("[triage] LLM call failed: %v — falling through to webhook", err) return nil } return result } // callLLM calls the OpenAI-compatible API func callLLM(provider ProviderConfig, systemPrompt, userMessage string) (*TriageResult, error) { reqBody := map[string]interface{}{ "model": provider.Model, "messages": []map[string]string{ {"role": "system", "content": systemPrompt}, {"role": "user", "content": userMessage}, }, "temperature": 0.1, "max_tokens": 256, "response_format": map[string]string{"type": "json_object"}, } data, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } url := strings.TrimRight(provider.BaseURL, "/") + "/chat/completions" req, err := http.NewRequest("POST", url, bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+provider.APIKey) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != 200 { return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } // Parse OpenAI-compatible response var apiResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &apiResp); err != nil { return nil, fmt.Errorf("parse API response: %w", err) } if len(apiResp.Choices) == 0 { return nil, fmt.Errorf("no choices in API response") } content := apiResp.Choices[0].Message.Content // Parse the triage result JSON var result TriageResult if err := json.Unmarshal([]byte(content), &result); err != nil { return nil, fmt.Errorf("parse triage result %q: %w", content, err) } // Validate action switch result.Action { case "junk", "pass": // valid default: return nil, fmt.Errorf("unknown triage action: %q", result.Action) } return &result, nil } // executeTriageAction performs the action decided by the LLM func executeTriageAction(msg UnifiedMessage, result TriageResult) error { switch result.Action { case "junk": if err := store.Delete(msg.ID); err != nil { return fmt.Errorf("junk delete: %w", err) } orch.RecordAction(msg.ID, "delete") case "pass": // Leave for webhook — record as seen so it shows in /messages/new for the agent source, sourceID, _ := parseMessageID(msg.ID) orch.RecordSeen(msg.ID, source, sourceID, "INBOX") } return nil } // logTriageDecision appends a structured log entry to the triage log file func logTriageDecision(msg UnifiedMessage, result TriageResult) { logPath := os.ExpandEnv("$HOME") + "/.message-center/triage-log.jsonl" sender := msg.From if msg.FromName != "" { sender = msg.FromName + " <" + msg.From + ">" } entry := triageLogEntry{ Ts: time.Now().UTC().Format(time.RFC3339), ID: msg.ID, From: sender, Subject: msg.Subject, Action: result.Action, Reason: result.Reason, } data, err := json.Marshal(entry) if err != nil { log.Printf("[triage] Failed to marshal log entry: %v", err) return } f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Printf("[triage] Failed to open triage log %s: %v", logPath, err) return } defer f.Close() f.Write(append(data, '\n')) } // logTriageToDashboard posts a log entry to the alert dashboard func logTriageToDashboard(dashboardURL string, msg UnifiedMessage, result TriageResult) { if dashboardURL == "" { return } sender := msg.FromName if sender == "" { sender = msg.From } priority := "info" if result.Action == "escalate" { priority = "warning" } alert := map[string]string{ "message": fmt.Sprintf("📧 [%s] %s: %s — %s", result.Action, sender, msg.Subject, result.Reason), "priority": priority, } data, _ := json.Marshal(alert) resp, err := http.Post(dashboardURL, "application/json", bytes.NewReader(data)) if err != nil { log.Printf("[triage] Dashboard alert failed: %v", err) return } resp.Body.Close() } // triageNewEmails fetches new emails from a specific account, triages them, // and returns true if any messages should escalate (fire webhook). func triageNewEmails(accountName string, cfg TriageConfig) bool { c, ok := store.GetConnector(accountName) if !ok { log.Printf("[triage] Connector %s not found", accountName) return true // fall through to webhook } // Only triage email connectors _, isEmail := c.(*EmailConnector) if !isEmail { return true // non-email connectors always webhook } msgs, err := c.FetchNew() if err != nil { log.Printf("[triage] Failed to fetch new from %s: %v", accountName, err) return true } if len(msgs) == 0 { return false } shouldEscalate := false for _, msg := range msgs { // Skip already-actioned messages if orch.HasAction(msg.ID) { continue } log.Printf("[triage] Processing %s: %s from %s", msg.ID, msg.Subject, msg.From) result := triageEmail(msg, cfg) if result == nil { // Triage failed — escalate this message shouldEscalate = true continue } log.Printf("[triage] %s → %s: %s", msg.ID, result.Action, result.Reason) if err := executeTriageAction(msg, *result); err != nil { log.Printf("[triage] Action failed for %s: %v — escalating", msg.ID, err) shouldEscalate = true continue } logTriageDecision(msg, *result) logTriageToDashboard(cfg.DashboardURL, msg, *result) if result.Action == "pass" { shouldEscalate = true } } return shouldEscalate }