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>
This commit is contained in:
@@ -18,6 +18,10 @@ 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.
|
||||
@@ -31,6 +35,9 @@ type Request struct {
|
||||
// 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
|
||||
}
|
||||
@@ -107,6 +114,11 @@ func BuildPrompt(req Request, tmpl Template) (system, user string) {
|
||||
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)
|
||||
|
||||
@@ -116,3 +116,16 @@ func TestGeneratorDraftEmptyReplyIsAnError(t *testing.T) {
|
||||
t.Fatal("Draft() error = nil, want an error for an empty reply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptIncludesRules(t *testing.T) {
|
||||
req := Request{
|
||||
Message: store.Message{FromAddr: "customer@example.test", Subject: "hi", BodyText: "?"},
|
||||
Action: "reply",
|
||||
Rules: `from contains "example.test" => category=Action`,
|
||||
}
|
||||
tmpl, _ := TemplateByID("answer")
|
||||
_, user := BuildPrompt(req, tmpl)
|
||||
if !strings.Contains(user, "USER RULES") || !strings.Contains(user, `from contains "example.test"`) {
|
||||
t.Errorf("user prompt does not include the rules document:\n%s", user)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user