Files
email_agent/internal/reply/draft.go
Sam Rolfe 00bb0e05c4 Phase 6: rules editor + unsubscribe detection (AUD-72)
- FR-11 rules editor: plain-text area at /rules, saved and versioned
  (SaveRules appends a version; restore re-saves an old version), parse
  errors surfaced, one-click re-classify. The reply model now reads the
  rules document too (FR-11).
- FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post
  (migration v6), detect email and web links (internal/unsubscribe), a
  domain-level ignore list, and an explicit record of what was marked. No
  code path contacts an unsubscribe link; acting on one needs Sam's approval.
- Migration v6 is additive; unsubscribe gains url/updated_at.
- Tests: detector, parser, store migration/records/candidates, rules editor,
  unsubscribe handlers, message-detail rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:27:08 +11:00

173 lines
5.5 KiB
Go

// 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
// maxRulesChars bounds how much of the user's rules document is sent to the
// model (FR-11: the LLM reads the rules too).
const maxRulesChars = 2000
// 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
// Rules is the current rules document from the editor (FR-11). The model
// reads it so reply style and handling follow the same rules as triage.
Rules 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 rules := strings.TrimSpace(req.Rules); rules != "" {
b.WriteString("USER RULES (follow when they apply):\n")
b.WriteString(truncate(rules, maxRulesChars))
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]) + "…"
}