f42d1594ef
The extraction half. internal/email.Extractor asks the resident Qwen3-1.7B, under a GBNF grammar, what one message requires of him, and returns at most three short candidates with an optional date. Everything it can produce is a row in `tasks` with status "candidate", written through the intake seam #130 built for exactly this (Source "email:<mailbox>", Evidence = the subject line). No reminder, no fact, no note, no calendar event. That bound is the design: a reminder FIRES, so a 1.7B misreading "встреча была в четверг" as a future appointment would wake him up about it, whereas a wrong candidate is a line he dismisses in one click. A due date the model read out of the mail is stored on the candidate, where no scheduler reads it — the review page sorts by it. Relative wording ("до пятницы") is deliberately left in the text rather than resolved to a date the model would get wrong. The prompt is written against the two things a small model does here: it summarises when asked to extract, and it invents an obligation out of a polite closing line. Hence the demand for a verb phrase, and an explicit empty array — most mail contains no task, and a model with no way to say "nothing" says something. Wiring: core owns extraction because llama-server lives in core's process, so the reader hands messages over a new ipc.MethodIngestMail. It is a Server hook (like StepUp/UnlockFn), not a CoreAPI method — not a store operation, and no CoreAPI implementation should have to carry it. The hook stays nil without an `email` config block or without a llama-server phraser, so the method answers ErrUnknownMethod: off unless configured, twice over. There is no keyword fallback on purpose — "the subject became a task" is a mailbox rendered as a to-do list, not extraction. Privacy: junk is refused before the model is called, mail text is never search input, extraction errors carry byte counts rather than the reply, the stored evidence is a truncated subject, and the log line names the mailbox and the UID only.
144 lines
4.9 KiB
Go
144 lines
4.9 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/llm"
|
|
)
|
|
|
|
// fakeLLM returns a canned reply and records the request, so a test can assert
|
|
// on the grammar and on what of the mail was sent.
|
|
type fakeLLM struct {
|
|
reply string
|
|
err error
|
|
got llm.Req
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
|
f.calls++
|
|
f.got = r
|
|
return f.reply, f.err
|
|
}
|
|
|
|
func msgFor(subject, body string) Message {
|
|
return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body}
|
|
}
|
|
|
|
func TestExtractCandidates(t *testing.T) {
|
|
f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`}
|
|
e := NewExtractor(f, 0, nil)
|
|
got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа."))
|
|
if err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
|
}
|
|
if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" {
|
|
t.Errorf("candidates = %+v", got)
|
|
}
|
|
if f.got.Grammar == "" {
|
|
t.Error("extraction must be grammar-constrained")
|
|
}
|
|
// The subject and body go to the model; nothing else about the message does.
|
|
if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") {
|
|
t.Errorf("user turn = %q", f.got.User)
|
|
}
|
|
}
|
|
|
|
func TestExtractEmptyArrayIsNotAnError(t *testing.T) {
|
|
f := &fakeLLM{reply: "[]"}
|
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению."))
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err)
|
|
}
|
|
}
|
|
|
|
// Junk must never reach the model: the header filter exists so the resident
|
|
// model is not spent on newsletters.
|
|
func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) {
|
|
f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`}
|
|
msg := msgFor("Скидки", "Sale!")
|
|
msg.Junk = true
|
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg)
|
|
if err != nil || got != nil {
|
|
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
|
|
}
|
|
if f.calls != 0 {
|
|
t.Errorf("model called %d times for junk, want 0", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestExtractEmptyMessageIsNotSent(t *testing.T) {
|
|
f := &fakeLLM{reply: "[]"}
|
|
if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if f.calls != 0 {
|
|
t.Errorf("model called %d times for an empty message, want 0", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestExtractCaps(t *testing.T) {
|
|
f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`}
|
|
got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b"))
|
|
if err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Errorf("got %d, want the configured cap of 2", len(got))
|
|
}
|
|
}
|
|
|
|
func TestExtractDropsRepeatsAndBadDates(t *testing.T) {
|
|
f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`}
|
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b"))
|
|
if err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got)
|
|
}
|
|
if got[0].Due != "" {
|
|
t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due)
|
|
}
|
|
}
|
|
|
|
// A Thinking model sometimes wraps the array; and when it emits something
|
|
// unparsable the caller must hear about it rather than see "no tasks".
|
|
func TestParseCandidatesTolerance(t *testing.T) {
|
|
got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё")
|
|
if err != nil || len(got) != 1 || got[0].Text != "x" {
|
|
t.Fatalf("got (%+v, %v)", got, err)
|
|
}
|
|
if _, err := parseCandidates("нет никакого JSON"); err == nil {
|
|
t.Error("unparsable output must be an error")
|
|
}
|
|
}
|
|
|
|
func TestExtractParseErrorHidesMailText(t *testing.T) {
|
|
f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"}
|
|
_, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст"))
|
|
if err == nil {
|
|
t.Fatal("want an error")
|
|
}
|
|
if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") {
|
|
t.Errorf("error text leaks mail content: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseDue(t *testing.T) {
|
|
if _, ok := ParseDue(""); ok {
|
|
t.Error("empty due must be (zero, false)")
|
|
}
|
|
if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 {
|
|
t.Errorf("ParseDue = (%v, %v)", got, ok)
|
|
}
|
|
if _, ok := ParseDue("05.08.2026"); ok {
|
|
t.Error("a non-ISO date must not parse")
|
|
}
|
|
}
|