package email import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/persona" ) // Extraction — turning one mail into task CANDIDATES, and nothing else. // // The output of this file can only ever become rows in `tasks` with status // "candidate" (store.TaskCandidate), written through the one intake seam // (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design: // // - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that // misreads "встреча была в четверг" as a future appointment would then wake // him up about it. A candidate that is wrong is a line on a review page he // dismisses in one click, which is the correct cost of a model being wrong // about someone's mail. // - No fact. A fact is a claim Maven will later recite as true. Nothing read // out of a marketing mail deserves that standing. // - No calendar event, no note, no action. Extraction writes candidates or // writes nothing. // // The due date the model may return is stored on the candidate (tasks.due_ts), // which no scheduler reads — it is there so the review page can sort by it. // // Privacy: the mail text goes to the resident model on this box and nowhere // else. It is never search input (CLAUDE.md: "his notes and facts are never // search input" — mail is the same class), and Evidence keeps only the subject // line, so the review page shows him where a candidate came from without the // store growing a copy of his mailbox. // MaxCandidates — at most this many candidates per message, enforced by the // grammar. A mail with four tasks in it is a mail he has to read himself; a // model allowed ten will produce ten. const MaxCandidates = 3 // SourcePrefix — provenance for everything this package captures. The mailbox // name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata. const SourcePrefix = "email:" // Candidate — one piece of work the model thinks the mail is asking for. type Candidate struct { Text string `json:"text"` // Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a // date it computed: relative wording ("до пятницы") is left in Text, because // a small model resolving "пятница" against today's date gets it wrong often // enough that a stored wrong date is worse than no date. Due string `json:"due"` } // Completer — the llama-server seam, same shape memeval and the router use, so // the one resident model serves this caller too. type Completer interface { Complete(ctx context.Context, r llm.Req) (string, error) } // Extractor reads a message and returns candidates. It holds no store and no // writer on purpose: this type cannot persist anything, so "extraction never // acts" is a property of the code, not of a review. type Extractor struct { llm Completer // MaxCandidates — 0 ⇒ MaxCandidates. max int // ContextBlock — the shared persona block, optional. Extraction output is // not spoken, so the persona matters less here than in the phraser; it is // wired anyway so a candidate reads in her voice on the review page. contextBlock func() string } func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor { if max <= 0 || max > MaxCandidates { max = MaxCandidates } return &Extractor{llm: c, max: max, contextBlock: contextBlock} } // extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape // candidates. Same reasoning as memeval's evalGrammar and the router's // routeGrammar: the shape and the length bound are what keep a small model from // drifting into prose or spending the token budget repeating one field. // // The empty array is reachable, deliberately: most mail contains no task, and a // model with no way to say "nothing" invents something. const extractGrammar = ` root ::= "[" ws (item ("," ws item){0,2})? ws "]" item ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}" text ::= "\"" ([^"\\] | "\\" .){1,120} "\"" due ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\"" ws ::= [ \t\n]* ` // extractSystem — the extraction prompt. // // Written around the two failure modes a small model has on this task: it // summarises when asked to extract (turning a mail into "письмо от Антона"), // and it invents an obligation from any polite closing sentence. Hence the // insistence on a verb phrase, and the explicit permission to return []. const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует. Правила: - Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}. - text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его. - Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат. - Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего. - Ничего не придумывай. Если срока в письме нет — "". - due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй. - Максимум три дела. Лучше одно точное, чем три общих.` // Extract returns the candidates in one message. // // Junk is refused without an LLM call — cheapest possible defence, and the // reason the header filter exists. An empty message (no subject, no body) is // likewise not worth a round trip. // // A parse failure is an error the caller logs and moves past. It is never // silently turned into zero candidates, because "the model went off the rails" // and "the mail contains no task" want different reactions from a human reading // the log. func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) { if msg.Junk { return nil, nil } user := renderForModel(msg) if user == "" { return nil, nil } raw, err := e.llm.Complete(ctx, llm.Req{ System: persona.Prepend(e.contextBlock, extractSystem), User: user, Grammar: extractGrammar, MaxTokens: 512, RepeatPenalty: 1.1, }) if err != nil { return nil, fmt.Errorf("email: extract: %w", err) } items, err := parseCandidates(raw) if err != nil { // The raw reply is NOT in the error: it is a transformation of his mail, // and this error reaches the daemon log. return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw)) } out := make([]Candidate, 0, len(items)) seen := map[string]bool{} for _, it := range items { it.Text = strings.TrimSpace(it.Text) if it.Text == "" { continue } key := strings.ToLower(strings.Join(strings.Fields(it.Text), " ")) if seen[key] { continue // the model repeating itself is not two tasks } seen[key] = true if _, ok := ParseDue(it.Due); !ok { it.Due = "" // a date the grammar allowed but the calendar does not } out = append(out, it) if len(out) >= e.max { break } } return out, nil } // renderForModel is the user turn: subject, sender and body, labelled. Only // these three fields — no headers, no recipient list, no message-id, nothing // that would let the model start reasoning about routing metadata. func renderForModel(msg Message) string { var b strings.Builder if msg.From != "" { fmt.Fprintf(&b, "От: %s\n", msg.From) } if msg.Subject != "" { fmt.Fprintf(&b, "Тема: %s\n", msg.Subject) } if msg.Body != "" { fmt.Fprintf(&b, "\n%s\n", msg.Body) } if msg.Subject == "" && msg.Body == "" { return "" } return b.String() } // parseCandidates decodes the grammar-constrained reply, tolerating the // wrappers a Thinking model sometimes leaves around it (a fenced block, or // leading reasoning before the array). func parseCandidates(raw string) ([]Candidate, error) { s := strings.TrimSpace(raw) if i := strings.Index(s, "["); i > 0 { s = s[i:] } if j := strings.LastIndex(s, "]"); j >= 0 { s = s[:j+1] } var out []Candidate if err := json.Unmarshal([]byte(s), &out); err != nil { return nil, err } return out, nil } // ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because // the daemon-side intake stores it on the candidate. // // The zero-value/empty case returns ok=false rather than an error: no date is // the common answer, not a failure. func ParseDue(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } t, err := time.Parse("2006-01-02", s) if err != nil { return time.Time{}, false } return t, true }