Phase 5: reply drafts — template/LLM draft, edit, SMTP send (AUD-71)
Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15): - Message detail pane (GET /partials/message) with body, recommended action, reply identity, template picker, editable draft, and history. - internal/reply: OmniRoute (OpenAI-compatible) chat client and a template-based draft generator. All non-JEV models go through OmniRoute (§10). - internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and the app password, forcing IPv4 (§9); FR-15 reply identity (send as the arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via In-Reply-To/References; -check-smtp safe verification; and a [Gmail]/Sent Mail thread check. - Migration v5 stores messages.message_id_header and drafts.sent_at; mailfetch parses Message-ID. - Send is user-triggered only: CSRF + UI confirm + confirm=yes guard, no auto-send, duplicate-send refused. A sent reply files the original under Replied-to/<address> and logs every step (FR-13). - Tests for reply, mailops SMTP/identity/thread check, store drafts and migration v5, and the server draft/edit/send guards. - docs/phase5-replies.md, README, .env.example, docker-compose updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
160
internal/reply/draft.go
Normal file
160
internal/reply/draft.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user