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.
227 lines
9.1 KiB
Go
227 lines
9.1 KiB
Go
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
|
|
}
|