// Draft generation for Phase 5 (FR-8): turn a message into a short reply using // a template and the OmniRoute gateway. package reply import ( "context" "fmt" "log/slog" "strings" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // maxPromptBodyChars bounds how much of the original message is sent to the // model. Personal mail is usually short; this keeps the request cheap. const maxPromptBodyChars = 4000 // maxDraftChars bounds the generated body before it is stored. const maxDraftChars = 8000 // Request is one draft request. type Request struct { // Message is the mail being replied to. Message store.Message // Category, Importance, and Action come from the latest classification. Category string Importance string Action string // TemplateID selects a built-in template. Empty picks one from Action. TemplateID string // Instruction is optional extra guidance Sam typed (for example "say the // invoice is paid"). It is appended to the template instruction. Instruction string // Signer is the name used for the sign-off (default "Sam"). Signer string } // Generator produces reply drafts from an LLM client. type Generator struct { client *Client logger *slog.Logger } // NewGenerator builds a Generator. func NewGenerator(client *Client, logger *slog.Logger) *Generator { if logger == nil { logger = slog.New(slog.DiscardHandler) } return &Generator{client: client, logger: logger} } // Configured reports whether the LLM gateway is configured. func (g *Generator) Configured() bool { return g.client.Configured() } // Model returns the configured model alias. func (g *Generator) Model() string { return g.client.Model() } // Draft generates a reply body and returns it with the template that was used. func (g *Generator) Draft(ctx context.Context, req Request) (body, templateID string, err error) { tmpl, ok := TemplateByID(req.TemplateID) if !ok { tmpl = TemplateForAction(req.Action) if strings.TrimSpace(req.TemplateID) != "" { g.logger.Warn("unknown reply template; using the action default", "template", req.TemplateID, "used", tmpl.ID) } } system, user := BuildPrompt(req, tmpl) body, err = g.client.Complete(ctx, system, user) if err != nil { return "", "", err } body = cleanDraft(body, maxDraftChars) if body == "" { return "", "", fmt.Errorf("the model returned an empty draft") } return body, tmpl.ID, nil } // BuildPrompt builds the system and user prompts for a draft. It is exported so // the prompt shape can be tested without a live gateway. func BuildPrompt(req Request, tmpl Template) (system, user string) { signer := strings.TrimSpace(req.Signer) if signer == "" { signer = "Sam" } system = strings.Join([]string{ "You write short, plain-text email replies on behalf of " + signer + ".", "Return only the reply body: no subject line, no markdown, no placeholders, no quoted history.", "Match the language of the incoming message.", "Keep replies under 120 words and end with a sign-off of \"" + signer + "\".", "If the incoming message is unclear, write a brief acknowledgement and ask one short question.", "Never invent facts, prices, dates, or commitments. If a detail is missing, leave it out or ask about it.", }, " ") var b strings.Builder b.WriteString("TEMPLATE: ") b.WriteString(tmpl.Name) b.WriteString("\n") b.WriteString("INSTRUCTION: ") b.WriteString(tmpl.Instruction) b.WriteString("\n") if extra := strings.TrimSpace(req.Instruction); extra != "" { b.WriteString("SAM'S NOTE: ") b.WriteString(extra) b.WriteString("\n") } if cat := strings.TrimSpace(req.Category); cat != "" { b.WriteString("CLASSIFICATION: category=") b.WriteString(cat) if imp := strings.TrimSpace(req.Importance); imp != "" { b.WriteString(" importance=") b.WriteString(imp) } if act := strings.TrimSpace(req.Action); act != "" { b.WriteString(" suggested_action=") b.WriteString(act) } b.WriteString("\n") } b.WriteString("\n--- ORIGINAL MESSAGE ---\n") b.WriteString("From: ") b.WriteString(strings.TrimSpace(req.Message.FromAddr)) b.WriteString("\n") b.WriteString("Arrived at: ") b.WriteString(strings.TrimSpace(req.Message.SourceAddress)) b.WriteString("\n") b.WriteString("Subject: ") b.WriteString(strings.TrimSpace(req.Message.Subject)) b.WriteString("\n\n") b.WriteString(truncate(req.Message.BodyText, maxPromptBodyChars)) b.WriteString("\n--- END ---\n\nWrite the reply body now.") return system, b.String() } // cleanDraft trims a generated draft. Some models wrap output in a code fence or // prefix it with a "Subject:" line even when told not to; strip those. func cleanDraft(s string, max int) string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "```") s = strings.TrimSuffix(s, "```") s = strings.TrimSpace(s) if strings.HasPrefix(strings.ToLower(s), "subject:") { if i := strings.IndexByte(s, '\n'); i >= 0 { s = strings.TrimSpace(s[i+1:]) } } return truncate(s, max) } func truncate(s string, max int) string { s = strings.TrimSpace(s) if len(s) <= max { return s } return strings.TrimSpace(s[:max]) + "…" }