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.
183 lines
5.8 KiB
Go
183 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/email"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/llm"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// mailLLM — a canned extraction reply.
|
|
type mailLLM struct {
|
|
reply string
|
|
calls int
|
|
}
|
|
|
|
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
|
m.calls++
|
|
return m.reply, nil
|
|
}
|
|
|
|
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
fake := &mailLLM{reply: reply}
|
|
return &mailIntake{
|
|
st: st,
|
|
ex: email.NewExtractor(fake, 0, nil),
|
|
timeout: 5 * time.Second,
|
|
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
|
}, st, fake
|
|
}
|
|
|
|
func ingestReq() ipc.IngestMailReq {
|
|
return ipc.IngestMailReq{
|
|
Mailbox: "INBOX", UID: 42,
|
|
From: "billing@isp.example",
|
|
Subject: "Счёт за интернет",
|
|
Body: "Оплатите счёт до 5 августа.",
|
|
}
|
|
}
|
|
|
|
// The one property that matters: a mail-derived task is a candidate, attributed
|
|
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
|
// written.
|
|
func TestIngestCapturesCandidates(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
|
t.Fatalf("resp = %+v, want one created task", resp)
|
|
}
|
|
tasks, err := st.ListTasks(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
got := tasks[0]
|
|
if got.Status != store.TaskCandidate {
|
|
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
|
}
|
|
if got.Source != "email:INBOX" {
|
|
t.Errorf("source = %q, want email:INBOX", got.Source)
|
|
}
|
|
if got.Evidence != "Счёт за интернет" {
|
|
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
|
}
|
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
|
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
|
}
|
|
// Nothing else may have been written: no reminder, no fact.
|
|
rem, err := st.ListReminders(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rem) != 0 {
|
|
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
|
}
|
|
}
|
|
|
|
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
|
// rows, and the intake relies on exactly that.
|
|
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
|
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
|
t.Fatalf("first ingest: %v", err)
|
|
}
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("second ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
|
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
|
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
|
req := ingestReq()
|
|
req.Junk = true
|
|
resp, err := mi.ingest(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if !resp.Skipped || resp.Created != 0 {
|
|
t.Errorf("resp = %+v, want skipped", resp)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
|
mi, _, fake := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
|
if err != nil || !resp.Skipped {
|
|
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
|
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("got %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestTruncatesEvidence(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
|
req := ingestReq()
|
|
req.Subject = strings.Repeat("щ", 400)
|
|
if _, err := mi.ingest(context.Background(), req); err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
|
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
|
}
|
|
}
|
|
|
|
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
|
// exist at all.
|
|
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
|
st := newTestStore(t)
|
|
if mi := newMailIntake(st, nil, &config.Config{}); mi != nil {
|
|
t.Error("no email block must mean no mail intake")
|
|
}
|
|
// Configured but with a non-LLM phraser: still off — there is no fallback
|
|
// extraction, by design.
|
|
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}); mi != nil {
|
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
|
}
|
|
}
|