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:
184
internal/reply/client.go
Normal file
184
internal/reply/client.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Package reply drafts short replies with an LLM (FR-8).
|
||||
//
|
||||
// The model is reached through OmniRoute, the house AI gateway (AGENTS.md §8,
|
||||
// §10): every non-JEV model goes through it. OmniRoute exposes an
|
||||
// OpenAI-compatible /chat/completions endpoint, so this is a tiny client rather
|
||||
// than a vendor SDK.
|
||||
package reply
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the OmniRoute API on the host that runs this service (.13).
|
||||
// It matches the URL the other house apps use (dsh, worldmonitor). The
|
||||
// OMNIROUTE_BASE_URL environment variable overrides it.
|
||||
const DefaultBaseURL = "http://192.168.20.13:20129/v1"
|
||||
|
||||
// DefaultModel is the OmniRoute router alias for a good general chat model.
|
||||
const DefaultModel = "auto/best-chat"
|
||||
|
||||
// ErrNotConfigured means no OmniRoute credentials are available, so no draft can
|
||||
// be generated.
|
||||
var ErrNotConfigured = errors.New("OmniRoute is not configured: set OMNIROUTE_API_KEY")
|
||||
|
||||
// Client is a minimal OpenAI-compatible chat-completions client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
session string
|
||||
http *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewClient builds a Client. Empty baseURL/model fall back to the defaults.
|
||||
func NewClient(baseURL, apiKey, model string, logger *slog.Logger) *Client {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = DefaultModel
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
model: model,
|
||||
session: fmt.Sprintf("email-agent-%d-%d", time.Now().Unix(), rand.Int63()),
|
||||
http: &http.Client{Timeout: 90 * time.Second},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether an API key is present.
|
||||
func (c *Client) Configured() bool { return c.apiKey != "" }
|
||||
|
||||
// Model returns the configured model alias.
|
||||
func (c *Client) Model() string { return c.model }
|
||||
|
||||
// BaseURL returns the configured gateway URL (used in diagnostics only).
|
||||
func (c *Client) BaseURL() string { return c.baseURL }
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Complete sends a system and user prompt and returns the assistant text.
|
||||
func (c *Client) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
if !c.Configured() {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
payload, err := json.Marshal(chatRequest{
|
||||
Model: c.model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Stream: false,
|
||||
Temperature: 0.3,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode OmniRoute request: %w", err)
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
text, retryable, err := c.do(ctx, payload)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !retryable || attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
delay := time.Duration(1<<(attempt-1)) * time.Second
|
||||
c.logger.Warn("omniroute request failed; retrying",
|
||||
"attempt", attempt, "delay_ms", delay.Milliseconds(), "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, payload []byte) (string, bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("build OmniRoute request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
// Some OmniRoute upstreams (opencode-go) require a session id.
|
||||
req.Header.Set("x-opencode-session", c.session)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", true, fmt.Errorf("call OmniRoute: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", true, fmt.Errorf("read OmniRoute response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
retryable := resp.StatusCode == http.StatusTooManyRequests ||
|
||||
resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode >= 500
|
||||
return "", retryable, fmt.Errorf("OmniRoute returned %d: %s",
|
||||
resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var out chatResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", false, fmt.Errorf("decode OmniRoute response: %w", err)
|
||||
}
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return "", false, fmt.Errorf("OmniRoute error: %s", out.Error.Message)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", false, errors.New("OmniRoute response contained no choices")
|
||||
}
|
||||
text := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
if text == "" {
|
||||
return "", false, errors.New("OmniRoute returned an empty reply")
|
||||
}
|
||||
return text, false, nil
|
||||
}
|
||||
84
internal/reply/client_test.go
Normal file
84
internal/reply/client_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package reply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteSendsOpenAIRequestAndParsesReply(t *testing.T) {
|
||||
var (
|
||||
gotAuth string
|
||||
gotSession string
|
||||
gotPath string
|
||||
gotBody chatRequest
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotSession = r.Header.Get("x-opencode-session")
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(chatResponse{
|
||||
Model: "test-model",
|
||||
Choices: []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
}{{Message: chatMessage{Role: "assistant", Content: " Thanks, got it. "}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "secret-key", "auto/best-chat", slog.New(slog.DiscardHandler))
|
||||
if !c.Configured() {
|
||||
t.Fatal("client should be configured with a key")
|
||||
}
|
||||
got, err := c.Complete(context.Background(), "system prompt", "user prompt")
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
if got != "Thanks, got it." {
|
||||
t.Errorf("Complete() = %q, want trimmed reply", got)
|
||||
}
|
||||
if gotPath != "/chat/completions" {
|
||||
t.Errorf("path = %q, want /chat/completions", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer secret-key" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if gotSession == "" {
|
||||
t.Error("x-opencode-session header was not set")
|
||||
}
|
||||
if gotBody.Model != "auto/best-chat" || gotBody.Stream {
|
||||
t.Errorf("request = %+v, want non-streaming auto/best-chat", gotBody)
|
||||
}
|
||||
if len(gotBody.Messages) != 2 || gotBody.Messages[0].Role != "system" || gotBody.Messages[1].Role != "user" {
|
||||
t.Errorf("messages = %+v", gotBody.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteNotConfigured(t *testing.T) {
|
||||
c := NewClient("http://127.0.0.1:1/v1", "", "auto/best-chat", slog.New(slog.DiscardHandler))
|
||||
if c.Configured() {
|
||||
t.Fatal("client should not be configured without a key")
|
||||
}
|
||||
if _, err := c.Complete(context.Background(), "s", "u"); err != ErrNotConfigured {
|
||||
t.Fatalf("Complete() error = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteReportsHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "key", "m", slog.New(slog.DiscardHandler))
|
||||
_, err := c.Complete(context.Background(), "s", "u")
|
||||
if err == nil {
|
||||
t.Fatal("Complete() error = nil, want an error")
|
||||
}
|
||||
}
|
||||
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]) + "…"
|
||||
}
|
||||
118
internal/reply/draft_test.go
Normal file
118
internal/reply/draft_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package reply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
func TestTemplateForAction(t *testing.T) {
|
||||
if got := TemplateForAction("reply").ID; got != "answer" {
|
||||
t.Errorf("TemplateForAction(reply) = %q, want answer", got)
|
||||
}
|
||||
if got := TemplateForAction("keep").ID; got != "quick-ack" {
|
||||
t.Errorf("TemplateForAction(keep) = %q, want quick-ack", got)
|
||||
}
|
||||
if _, ok := TemplateByID("DECLINE"); !ok {
|
||||
t.Error("TemplateByID should match case-insensitively")
|
||||
}
|
||||
if _, ok := TemplateByID("nonsense"); ok {
|
||||
t.Error("TemplateByID should reject an unknown template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptIncludesContext(t *testing.T) {
|
||||
req := Request{
|
||||
Message: store.Message{
|
||||
FromAddr: "customer@example.test",
|
||||
SourceAddress: "hello@where-woof.com",
|
||||
Subject: "Booking for Saturday",
|
||||
BodyText: "Can we book Saturday at 10?",
|
||||
},
|
||||
Category: "Action",
|
||||
Importance: "high",
|
||||
Action: "reply",
|
||||
Instruction: "Confirm 10am works",
|
||||
Signer: "Sam",
|
||||
}
|
||||
tmpl, _ := TemplateByID("confirm")
|
||||
system, user := BuildPrompt(req, tmpl)
|
||||
|
||||
if !strings.Contains(system, "Return only the reply body") {
|
||||
t.Errorf("system prompt missing the output constraint: %q", system)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Confirm an arrangement",
|
||||
"Confirm 10am works",
|
||||
"category=Action",
|
||||
"importance=high",
|
||||
"suggested_action=reply",
|
||||
"customer@example.test",
|
||||
"hello@where-woof.com",
|
||||
"Booking for Saturday",
|
||||
"Can we book Saturday at 10?",
|
||||
} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Errorf("user prompt missing %q\n%s", want, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanDraftStripsFenceAndSubject(t *testing.T) {
|
||||
if got := cleanDraft("```\nHello there.\n```", 1000); got != "Hello there." {
|
||||
t.Errorf("cleanDraft fence = %q", got)
|
||||
}
|
||||
if got := cleanDraft("Subject: Re: hi\n\nHello there.", 1000); got != "Hello there." {
|
||||
t.Errorf("cleanDraft subject = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorDraftUsesTemplate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(chatResponse{
|
||||
Choices: []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
}{{Message: chatMessage{Role: "assistant", Content: "Sure, Saturday works.\n\nSam"}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
g := NewGenerator(NewClient(srv.URL, "key", "auto/best-chat", slog.New(slog.DiscardHandler)),
|
||||
slog.New(slog.DiscardHandler))
|
||||
body, templateID, err := g.Draft(context.Background(), Request{
|
||||
Message: store.Message{FromAddr: "a@b.test", Subject: "hi", BodyText: "?"},
|
||||
Action: "reply",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Draft() error = %v", err)
|
||||
}
|
||||
if templateID != "answer" {
|
||||
t.Errorf("template = %q, want answer (the default for action=reply)", templateID)
|
||||
}
|
||||
if !strings.Contains(body, "Saturday") {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorDraftEmptyReplyIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(chatResponse{
|
||||
Choices: []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
}{{Message: chatMessage{Role: "assistant", Content: " "}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
g := NewGenerator(NewClient(srv.URL, "key", "m", slog.New(slog.DiscardHandler)),
|
||||
slog.New(slog.DiscardHandler))
|
||||
if _, _, err := g.Draft(context.Background(), Request{Message: store.Message{Subject: "x"}}); err == nil {
|
||||
t.Fatal("Draft() error = nil, want an error for an empty reply")
|
||||
}
|
||||
}
|
||||
71
internal/reply/templates.go
Normal file
71
internal/reply/templates.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Reply templates for Phase 5 (FR-8).
|
||||
//
|
||||
// A template is a short instruction that shapes the LLM draft. It is not the
|
||||
// final text: the model fills it in from the message, the classification, and
|
||||
// any extra note Sam types. Templates are built in for v1; a template editor is
|
||||
// not in the Phase 5 requirements.
|
||||
package reply
|
||||
|
||||
import "strings"
|
||||
|
||||
// Template is one reply shape.
|
||||
type Template struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
// Instruction is the template-specific part of the prompt.
|
||||
Instruction string
|
||||
}
|
||||
|
||||
// Templates is the built-in template list, in menu order.
|
||||
var Templates = []Template{
|
||||
{
|
||||
ID: "quick-ack",
|
||||
Name: "Quick acknowledgement",
|
||||
Description: "Confirm receipt in one or two short sentences.",
|
||||
Instruction: "Acknowledge that the message was received. If the sender asked for something, say when Sam will look at it. Do not promise anything Sam has not agreed to.",
|
||||
},
|
||||
{
|
||||
ID: "answer",
|
||||
Name: "Answer the question",
|
||||
Description: "Answer the sender's question directly.",
|
||||
Instruction: "Answer the sender's question directly and briefly. If the message does not contain a clear question, summarise what Sam understood and ask one clarifying question.",
|
||||
},
|
||||
{
|
||||
ID: "confirm",
|
||||
Name: "Confirm an arrangement",
|
||||
Description: "Confirm a booking, time, or arrangement.",
|
||||
Instruction: "Confirm the arrangement in the message: restate the key detail (date, time, or reference) and say it works for Sam.",
|
||||
},
|
||||
{
|
||||
ID: "decline",
|
||||
Name: "Politely decline",
|
||||
Description: "Decline or say no thanks, politely.",
|
||||
Instruction: "Politely decline the request or offer. Thank the sender and keep it warm but brief. Do not over-explain.",
|
||||
},
|
||||
}
|
||||
|
||||
// TemplateByID returns the template with the given id. ok is false when it is
|
||||
// unknown. The id match is case-insensitive.
|
||||
func TemplateByID(id string) (Template, bool) {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
for _, t := range Templates {
|
||||
if t.ID == id {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return Template{}, false
|
||||
}
|
||||
|
||||
// TemplateForAction picks a sensible default template from the classifier's
|
||||
// recommended action. An unknown action falls back to a quick acknowledgement.
|
||||
func TemplateForAction(action string) Template {
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "reply":
|
||||
t, _ := TemplateByID("answer")
|
||||
return t
|
||||
default:
|
||||
t, _ := TemplateByID("quick-ack")
|
||||
return t
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user