composer

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: AGPL-3.0 Imports: 20 Imported by: 0

Documentation

Overview

Package composer holds the two ways a user tries rules out before they act: the rule composer, which turns free text into validated draft rules, and the rule tester, which runs saved or draft rules over recent mail. Neither saves anything, and neither can change a mailbox: all they are given of one is Reader.

Index

Constants

View Source
const (
	DefaultLimit = 200
	MaxLimit     = 2000
)

Limits of one test run.

View Source
const MaxSamples = 5

MaxSamples is how many matching emails a draft card shows.

View Source
const MaxText = 4000

MaxText is the longest text the composer takes, in characters.

Variables

View Source
var ErrModel = errors.New("the composer model failed")

ErrModel marks a failure of the generative model: it could not be reached, or its answer was not something drafts can be read from.

Functions

func ActionsSchema

func ActionsSchema() map[string]any

ActionsSchema is a rule's list of actions in an output schema.

func CheckText

func CheckText(text string) string

CheckText says what is wrong with a text to compose from, or "" when it can be used.

func CondSchema

func CondSchema() map[string]any

CondSchema is a condition tree in an output schema: a leaf or an all/any group, with no other keys, two levels deep (deeper nodes are any object), so a model cannot wrap a leaf in an object named after its field. {} (no conditions) fits it too.

func RuleGrammar

func RuleGrammar() string

RuleGrammar says how a rule's conditions and actions are written, from the one list of fields and action types the daemon accepts, so no prompt that asks for rules can drift from what validation takes.

func RuleLine

func RuleLine(r rules.Rule) string

RuleLine is how a rule is shown to the model: the fields it may reason about, as JSON.

Types

type CheckProgress

type CheckProgress struct {
	Done  int
	Total int // emails this check goes through: at most the newest limit of the range
	// Matched is how many emails the folder holds in the chosen range before the limit,
	// known once the mail is listed. It is more than Total when the limit cut the range.
	Matched    int
	ModelCalls int
	Tokens     int
	CostUSD    float64
}

CheckProgress reports how far a check has got. The daemon turns it into a check.progress event and into the "N of M checked", model-call and cost totals the screen shows.

type CheckRow

type CheckRow struct {
	Ref        mail.MsgRef
	From       string
	Subject    string
	ReceivedAt int64 // 0 = unknown
	Outcome    pipeline.Outcome
}

CheckRow is one checked email: how the real flow settled it, kept so a cleanup Sort can apply the same answer without asking the model again. Ref says where the email was when it was checked; Sort verifies it is still there. The display fields live only in the daemon's memory and are never logged or written to the database (MAI-44).

type Composer

type Composer struct {
	Store     *store.Store
	Gen       models.Generator
	Now       func() time.Time // nil = time.Now
	BodyChars int
}

Composer turns free text into draft rules. It saves nothing.

func (Composer) Compose

func (c Composer) Compose(ctx context.Context, req Request) (Output, error)

Compose runs the compose flow: build the context (the user's rules, the folders that exist, the condition fields), have the model write drafts through a forced tool call, validate every draft with the validator saved rules get, attaching what is wrong to the draft instead of failing the request, and test the valid drafts on the account's newest mail. Nothing is saved.

func (Composer) Folders

func (c Composer) Folders(ctx context.Context, acct *store.Account) ([]string, error)

Folders lists the folder names that exist: the account's, or with no account every account's.

type Conflict

type Conflict struct {
	RuleID int64  `json:"rule_id"`
	Kind   string `json:"kind"` // overlap | duplicate | shadowed
	Note   string `json:"note"`
}

Conflict is an existing rule a draft collides with.

type Draft

type Draft struct {
	Name       string         `json:"name"`
	Said       string         `json:"said"`
	Intent     *string        `json:"intent"`
	Conditions rules.Cond     `json:"conditions"`
	Exceptions rules.Cond     `json:"exceptions"`
	Actions    []rules.Action `json:"actions"`
	// AccountID, Stack and Model are the rest of a rule, so a draft can be saved as it is.
	// The composer does not write them: a new draft is for every account, does not stack
	// and has no model of its own; a re-optimized rule keeps what it had.
	AccountID     *int64     `json:"account_id"`
	Stack         bool       `json:"stack"`
	Model         string     `json:"model"`
	MinConfidence *float64   `json:"min_confidence"`
	NewFolders    []string   `json:"new_folders"`
	Question      *string    `json:"question"`
	Conflicts     []Conflict `json:"conflicts"`
	Errors        []Problem  `json:"errors"`
	MatchCount    int        `json:"match_count"`
	// Tested is how many emails MatchCount is out of: the newest ones the draft was tested
	// on, or, for a suggestion, the emails scanned. 0 when it was not tested.
	Tested  int   `json:"tested"`
	Samples []Row `json:"samples"`
}

Draft is one proposed rule, as a card to review. Its JSON is the contract's RuleDraft.

func ReadDraft

func ReadDraft(raw json.RawMessage, named string, existing []rules.Rule, folders []string) Draft

ReadDraft reads one rule an AI wrote with no words of the owner's to cut its wording from (Said stays empty), validated as a composed draft is: a rule suggested from the mail. named lists the folders the AI proposed to create, so they count as named.

func (*Draft) Fail

func (d *Draft) Fail(path, message string)

Fail attaches a problem to the card at the path of the field it is about, unless one is already there for that path.

func (Draft) Rule

func (d Draft) Rule() rules.Rule

Rule is the draft as a rule, enabled, to validate or to test.

type FetchOptions

type FetchOptions struct {
	// NoBody reads the headers only: no text is fetched, and Summary.Body is empty.
	NoBody bool
	// BodyChars cuts the parsed text to this many characters; 0 keeps all the connector
	// read.
	BodyChars int
}

FetchOptions says how much of each email's text Scanner.Fetch reads.

type Output

type Output struct {
	Drafts   []Draft
	Unparsed []string // parts of the text that could not be turned into a rule
}

Output is what a compose call answers.

type Problem

type Problem struct {
	Path    string `json:"path"`
	Message string `json:"message"`
}

Problem is one thing wrong with a draft, at the path of the field it is about.

type Progress

type Progress struct {
	Done       int // messages tested or passed over
	Total      int // messages the run will go through, known once the mail is listed
	ModelCalls int // model calls made so far
}

Progress is how far a run has got. A run that finds no mail reports one 0 of 0.

type Reader

type Reader interface {
	FetchMany(ctx context.Context, refs []mail.MsgRef, maxBody int) ([]*message.Raw, error)
	FetchSince(ctx context.Context, folder string, since time.Time, limit int) ([]mail.MsgRef, int, error)
}

Reader is the read-only part of mail.Mailbox. FetchMany reads with BODY.PEEK, so a tested message stays unread; there is no method here that could move or flag one.

type Request

type Request struct {
	UserID int64
	Text   string
	// Rule, when set, is re-optimized: the model is given its original wording with Text
	// and answers with a single draft to replace it.
	Rule *rules.Rule
	// Account is the mailbox the drafts are checked against: its folders are the ones that
	// exist, and its newest mail is what the drafts are tested on. nil = no mailbox is
	// connected: the folders of every account count, and nothing is tested.
	Account *store.Account
	Mailbox Reader           // nil = the account is offline: the drafts are not tested
	Decider pipeline.Decider // decides the test run; a nil Router leaves intent drafts untested
}

Request is one compose call.

type Result

type Result struct {
	Rows       []Row   `json:"results"` // newest first
	Tested     int     `json:"tested"`
	Matched    int     `json:"matched"` // a rule or sender rule would have applied
	ModelCalls int     `json:"model_calls"`
	CostUSD    float64 `json:"cost_usd"`
}

Result is a finished test run. Its JSON is the contract's TestResult.

type Row

type Row struct {
	From       string         `json:"from"`
	Subject    string         `json:"subject"`
	ReceivedAt *int64         `json:"received_at"`
	Stage      string         `json:"stage"`
	RuleID     *int64         `json:"rule_id"` // null = no rule, or a draft (see rule_name)
	RuleName   string         `json:"rule_name"`
	Confidence float64        `json:"confidence"`
	Reason     string         `json:"reason"`
	Review     bool           `json:"review"` // below the threshold: it would go to Needs review
	Actions    []rules.Action `json:"actions"`
	// contains filtered or unexported fields
}

Row is what one email would get. Its JSON is the contract's TestRow.

type Scanner

type Scanner struct {
	Store     *store.Store // the contacts index, for the is_contact and replied_before signals
	Mailbox   Reader
	AccountID int64
}

Scanner reads the mail already in one account's folders for a feature that is not in this package (ext.Mail): the same listing and BODY.PEEK fetches the tester uses, so nothing is marked read, and it is given only Reader, so it cannot move or flag anything.

func (Scanner) Fetch

func (s Scanner) Fetch(ctx context.Context, refs []mail.MsgRef, opts FetchOptions, each func(ctx context.Context, i int, email *message.Summary, seen bool) error) error

Fetch reads the listed emails with BODY.PEEK, in batches, and calls each for every one with its index in refs, whether the owner has read it and its summary, nil when it is gone or cannot be read as an email. each is called for several emails at once; its first error ends the fetch and is returned.

func (Scanner) List

func (s Scanner) List(ctx context.Context, folder string, since time.Time, limit int) ([]mail.MsgRef, int, error)

List lists the newest limit emails of folder received on or after since (zero = all of it), newest last, and how many the folder holds in that range before limit cut it.

type Tester

type Tester struct {
	Store     *store.Store // the contacts index, for the is_contact and replied_before signals
	Mailbox   Reader
	AccountID int64
	// Decider is the same step the live pipeline decides with. Give it routers made with
	// models.Router.For("test"), so the calls are booked as tests.
	Decider   pipeline.Decider
	BodyChars int
}

Tester runs rules over the newest mail of one folder and reports what each email would get. Bodies are held in memory for one batch of fetched messages only.

func (Tester) Check

func (t Tester) Check(ctx context.Context, rs []rules.Rule, senders []rules.SenderRule, folder string, since time.Time, limit int, progress func(CheckProgress)) ([]CheckRow, error)

Check runs every selected email of folder (the mail received since, newest limit kept) through the real flow — sender rules, conditions, then the decision model for rules with an intent, exactly as live processing decides — and returns one row per email with what was settled, newest first. It moves nothing and records nothing: the model is really asked and really paid (the caller books the calls under the "cleanup" purpose), but no mailbox is changed and no activity is written. progress, when set, is called once the mail is listed (0 of N) and after every email, never concurrently.

func (Tester) Run

func (t Tester) Run(ctx context.Context, rs []rules.Rule, senders []rules.SenderRule, folder string, limit int, progress func(Progress)) (Result, error)

Run evaluates rs (and the sender rules) against the newest limit messages of folder. Rules with an id below 1 are drafts: they take part like saved rules and are reported by name only. progress, when set, is called once the mail is listed (0 done) and after every message, never concurrently. A message that is gone or unreadable by the time it is fetched is left out.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL