assistant

package
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: AGPL-3.0 Imports: 31 Imported by: 0

Documentation

Overview

Package assistant is the model Exhale itself uses (ADR 0018, part B): plain language in chat and in the web, and reading statements and screenshots into the import review list.

It is off until the owner configures a provider and a key; nothing is sent anywhere otherwise. The model acts through the same op catalog as MCP, as the member who is talking, and every change it prepares is applied only after that member confirms it.

Index

Constants

View Source
const (
	// MaxRounds is how many times the model is called for one message.
	MaxRounds = 6
	// MaxProposals is how many changes one message can prepare.
	MaxProposals = 5
	// MaxMessageLen is the longest message a person can send, in bytes.
	MaxMessageLen = 4000
)

Limits. They bound what one message can cost and how long it can take.

View Source
const (
	StatusPending   = "pending"
	StatusApplied   = "applied"
	StatusDiscarded = "discarded"
	StatusFailed    = "failed"
	StatusUndone    = "undone"
)

Outcome statuses.

View Source
const (
	ProviderAnthropic = "anthropic"
	ProviderOpenAI    = "openai" // any OpenAI-compatible endpoint
)

Providers the owner can pick.

View Source
const (
	ScreenSubscriptions = "subscriptions"
	ScreenCharges       = "charges"
	ScreenTotal         = "total"
)

Screen kinds.

View Source
const (
	// MaxImageSize is the largest picture sent to a model.
	MaxImageSize = 5 << 20
	// MaxPDFSize is the largest PDF sent to a model.
	MaxPDFSize = 20 << 20
	// MaxTextSize is the largest text sent to a model. A longer file is
	// refused instead of being cut, so nothing is silently left out.
	MaxTextSize = 200_000
)

Import limits. A file is read in memory and never written anywhere.

View Source
const (
	// DefaultAnthropicModel is the model used when the owner does not pick
	// one.
	DefaultAnthropicModel = "claude-opus-5-5"
)

Anthropic defaults.

View Source
const DefaultOpenAIURL = "https://api.openai.com/v1"

DefaultOpenAIURL is used when the owner picks the OpenAI-compatible provider without an address.

View Source
const ReviewTTL = 24 * time.Hour

ReviewTTL is how long a review waits.

Variables

View Source
var ErrNeedsAssistant = domain.Invalid("reading a PDF, a picture or free text needs the built-in assistant. An owner can turn it on in Settings; a spreadsheet saved as CSV works without it")

ErrNeedsAssistant is returned for files only a model can read while the assistant is off.

View Source
var ErrNoReview = domain.NotFound("no import is waiting for review")

ErrNoReview is returned when the member has no review waiting.

View Source
var ErrPDFNeedsAssistant = domain.Invalid("Exhale found no list of transactions in this PDF. Export the statement as CSV from your bank, or ask an owner to turn on the built-in assistant in Settings to read it")

ErrPDFNeedsAssistant is returned for a PDF with text in which Exhale found no transactions, while the assistant is off.

View Source
var ErrPDFUnreadable = domain.Invalid("Exhale could not open this PDF (it may be damaged or protected with a password). Export the statement as CSV from your bank, or ask an owner to turn on the built-in assistant in Settings")

ErrPDFUnreadable is returned for a PDF Exhale cannot open, while the assistant is off.

View Source
var ErrScanNeedsAssistant = domain.Invalid("this PDF has no text (it is probably a scan): reading it needs the built-in assistant. An owner can turn it on in Settings; or export the statement as CSV from your bank")

ErrScanNeedsAssistant is returned for a PDF without text (a scan) while the assistant is off.

Functions

func Call

func Call(name string, args any) func(Request) (Response, error)

Call is a step where the model asks for one tool.

func CallWith

func CallWith(name string, args func(Request) any) func(Request) (Response, error)

CallWith is a step where the arguments depend on the request, e.g. an id that an earlier tool result returned.

func Explain

func Explain(err error) domain.Text

Explain turns an error into one sentence for the person, in a form each edge renders in their language.

func Fail

func Fail(err error) func(Request) (Response, error)

Fail is a step where the provider fails.

func IsKind

func IsKind(err error, k Kind) bool

IsKind reports whether err is an assistant failure of kind k.

func NewReviewID

func NewReviewID() string

NewReviewID returns a short id: chat buttons carry it.

func Say

func Say(text string) func(Request) (Response, error)

Say is a step where the model answers with text.

Types

type Anthropic

type Anthropic struct {
	// contains filtered or unexported fields
}

Anthropic talks to the Claude Messages API.

func NewAnthropic

func NewAnthropic(key, model, baseURL string, timeout time.Duration) *Anthropic

NewAnthropic returns a client for the Messages API. baseURL is empty for Anthropic's own API; set it for a gateway that speaks the same protocol.

func (*Anthropic) Complete

func (a *Anthropic) Complete(ctx context.Context, req Request) (Response, error)

Complete implements Provider.

func (*Anthropic) Name

func (a *Anthropic) Name() string

Name implements Provider.

func (*Anthropic) Stream

func (a *Anthropic) Stream(ctx context.Context, req Request, onText func(string)) (Response, error)

Stream implements Streamer.

type Assistant

type Assistant struct {
	// contains filtered or unexported fields
}

Assistant answers plain language with the household's data and prepares changes for the person to confirm.

func New

func New(svc *app.Service, log *slog.Logger, env Env) (*Assistant, error)

New returns an assistant. It is off until an owner configures it (or env does).

func (*Assistant) Ask

func (a *Assistant) Ask(ctx context.Context, actor app.Actor, conv, text string) (Turn, error)

Ask answers one message of a conversation. conv identifies the conversation (a chat, or a member's web panel); its last turns are kept for half an hour so follow-up questions work.

func (*Assistant) AskStream

func (a *Assistant) AskStream(ctx context.Context, actor app.Actor, conv, text string, progress Progress) (Turn, error)

AskStream is Ask, telling progress the answer as it is written when the provider can stream.

func (*Assistant) Config

func (a *Assistant) Config(ctx context.Context) Config

Config returns the effective configuration: what the web saved, with the environment's values on top.

func (*Assistant) Configure

func (a *Assistant) Configure(ctx context.Context, in Settings) (Status, error)

Configure validates and saves the owner's settings.

func (*Assistant) Decide

func (a *Assistant) Decide(ctx context.Context, actor app.Actor, id string, accept bool) (Outcome, error)

Decide applies a proposal (accept) or drops it. Only the member it was made for can decide, and only once: a second press returns what already happened.

func (*Assistant) DropReview

func (a *Assistant) DropReview(ctx context.Context, actor app.Actor) error

DropReview forgets the member's review.

func (*Assistant) Enabled

func (a *Assistant) Enabled(ctx context.Context) bool

Enabled reports whether the assistant answers: configured and switched on. While it is false nothing is sent to any provider.

func (*Assistant) LoadReview

func (a *Assistant) LoadReview(ctx context.Context, actor app.Actor) (Review, error)

LoadReview returns the member's review, if one is waiting.

func (*Assistant) ReadImport

func (a *Assistant) ReadImport(ctx context.Context, actor app.Actor, data []byte) (importer.Result, error)

ReadImport turns an uploaded file or pasted text into an import review list. Files Exhale understands by itself (a Wallos database, an export, a spreadsheet, a bank export with transactions) never reach a model, except that the merchant names of a bank export are tidied when the assistant is on. A PDF, a picture or free text is read by the model, which only proposes: the person ticks what to import.

func (*Assistant) ReadTerms

func (a *Assistant) ReadTerms(ctx context.Context, actor app.Actor, documentID string) (TermsProposal, error)

ReadTerms asks the model for the contract details in a document attached to a subscription, and returns them checked, as a proposal.

func (*Assistant) Remove

func (a *Assistant) Remove(ctx context.Context) (Status, error)

Remove forgets everything saved from the web, including the key.

func (*Assistant) Reset

func (a *Assistant) Reset(ctx context.Context, actor app.Actor, conv string)

Reset forgets the conversation.

func (*Assistant) SaveReview

func (a *Assistant) SaveReview(ctx context.Context, actor app.Actor, r Review) error

SaveReview keeps a review for the member, replacing any other.

func (*Assistant) SetProvider

func (a *Assistant) SetProvider(p Provider)

SetProvider makes the assistant use p whatever the settings say. Tests use it with the fake provider.

func (*Assistant) Status

func (a *Assistant) Status(ctx context.Context) Status

Status reports the configuration without the key.

func (*Assistant) Test

func (a *Assistant) Test(ctx context.Context) TestResult

Test makes one small call to the configured model, with a tool, and says in a sentence whether chat will work. It works while the switch is off, so the owner can test before turning it on.

func (*Assistant) ThisMonth

func (a *Assistant) ThisMonth(ctx context.Context, actor app.Actor) MonthUsage

ThisMonth totals the household's usage since the first of the month.

func (*Assistant) Tools

func (a *Assistant) Tools() []Tool

Tools lists the tools offered to the model.

func (*Assistant) Undo

func (a *Assistant) Undo(ctx context.Context, actor app.Actor, id string) (Outcome, error)

Undo reverts a change applied from a proposal, newest part first.

func (*Assistant) Usage

func (a *Assistant) Usage(ctx context.Context, actor app.Actor, owner bool, limit int) (UsageList, error)

Usage lists what the assistant was asked, newest first. An owner sees the whole household (they pay the provider); a member sees their own.

type Attachment

type Attachment struct {
	MediaType string // image/png, image/jpeg, image/webp, application/pdf
	Data      []byte
}

Attachment is a file the person sent with a message: a picture or a PDF.

type Config

type Config struct {
	Provider string
	APIKey   string
	Model    string
	BaseURL  string
	Enabled  bool
}

Config is the effective configuration.

func (Config) Ready

func (c Config) Ready() bool

Ready reports whether a provider can be built from c.

type Env

type Env struct {
	Provider string // EXHALE_ASSISTANT_PROVIDER
	APIKey   string // EXHALE_ASSISTANT_API_KEY
	Model    string // EXHALE_ASSISTANT_MODEL
	BaseURL  string // EXHALE_ASSISTANT_BASE_URL
}

Env is the configuration given by environment variables. Each value that is set wins over the one saved from the web.

type Error

type Error struct {
	Kind   Kind
	Status int
	Detail string
}

Error is a failure of the assistant. Detail is the provider's own message, for logs and for the settings test; it is not shown in chat.

func (*Error) Error

func (e *Error) Error() string

type Fake

type Fake struct {

	// Steps are answered in order. A step may look at the request (to
	// read an id from a tool result, say) before answering.
	Steps []func(Request) (Response, error)
	// Requests records every call, for assertions.
	Requests []Request
	// Loop, when set, answers every call after the script ran out.
	Loop func(Request) (Response, error)
	// contains filtered or unexported fields
}

Fake is a scripted provider for tests: each call to Complete answers with the next step of the script, so conversations are deterministic and no test touches the network.

func (*Fake) Calls

func (f *Fake) Calls() int

Calls returns how many times the model was called.

func (*Fake) Complete

func (f *Fake) Complete(_ context.Context, req Request) (Response, error)

Complete implements Provider.

func (*Fake) Name

func (f *Fake) Name() string

Name implements Provider.

func (*Fake) Stream

func (f *Fake) Stream(ctx context.Context, req Request, onText func(string)) (Response, error)

Stream implements Streamer: the scripted answer's text arrives word by word, then the whole answer is returned.

func (*Fake) Then

func (f *Fake) Then(steps ...func(Request) (Response, error)) *Fake

Then appends steps to the script.

type Kind

type Kind int

Kind classifies what went wrong, so the person gets a sentence they can act on instead of an HTTP status.

const (
	KindOther         Kind = iota
	KindNotConfigured      // the owner has not set the assistant up
	KindAuth               // the key was rejected
	KindNoAnswer           // timeout or the provider could not be reached
	KindRateLimited        // the provider asks to slow down
	KindBusy               // Exhale's own per-member limit
	KindModel              // the model does not exist or is not available
	KindRejected           // the provider refused the request as invalid
	KindTooLarge           // the request is larger than the provider accepts
	KindRefused            // the model declined to answer
	KindOverloaded         // the provider is failing or overloaded
	KindExpired            // the proposal is gone
)

Kinds of failure.

type Mention

type Mention struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

Mention is a subscription the answer talks about.

type Message

type Message struct {
	Role        Role
	Text        string
	Attachments []Attachment // user messages
	ToolCalls   []ToolCall   // assistant messages
	ToolResults []ToolResult // tool messages
	// Raw is the provider's own form of an assistant message. It is sent
	// back as it came while the same question is being answered, because
	// providers that return reasoning blocks reject a history that was
	// rebuilt without them. It is never kept between questions.
	Raw json.RawMessage
}

Message is one turn of a conversation, in a form every provider can send.

type MonthUsage

type MonthUsage struct {
	Month        string  `json:"month" doc:"e.g. 2026-11, in the household's time zone"`
	Questions    int     `json:"questions" doc:"questions and imports read by a model"`
	InputTokens  int     `json:"inputTokens"`
	OutputTokens int     `json:"outputTokens"`
	CostUSD      *string `json:"costUsd,omitempty" doc:"estimated cost in US dollars of the entries whose price is known"`
	Unpriced     int     `json:"unpriced" doc:"entries whose model has no price: their tokens count, their cost does not"`
}

MonthUsage is the total of one month.

type OpenAI

type OpenAI struct {
	// contains filtered or unexported fields
}

OpenAI talks to any endpoint that speaks the chat completions protocol: OpenAI, OpenRouter, Ollama, LM Studio, vLLM and others.

func NewOpenAI

func NewOpenAI(key, model, baseURL string, timeout time.Duration) *OpenAI

NewOpenAI returns a client for a chat completions endpoint. key may be empty for local servers.

func (*OpenAI) Complete

func (o *OpenAI) Complete(ctx context.Context, req Request) (Response, error)

Complete implements Provider.

func (*OpenAI) Name

func (o *OpenAI) Name() string

Name implements Provider.

func (*OpenAI) Stream

func (o *OpenAI) Stream(ctx context.Context, req Request, onText func(string)) (Response, error)

Stream implements Streamer.

type Outcome

type Outcome struct {
	ID      string        `json:"id"`
	Status  string        `json:"status" enum:"pending,applied,discarded,failed,undone"`
	Title   domain.Text   `json:"title"`
	Lines   []domain.Text `json:"lines"`
	CanUndo bool          `json:"canUndo" doc:"an applied change that Undo can revert"`
	// SubscriptionID is the subscription the change was about, for a link.
	SubscriptionID string `json:"subscriptionId,omitempty"`
}

Outcome is what happened to a proposal.

type Price

type Price struct {
	Input  string `json:"input" doc:"US dollars per million input tokens, e.g. 4 or 0.10"`
	Output string `json:"output" doc:"US dollars per million output tokens, e.g. 20 or 0.50"`
}

Price is what a model costs, in US dollars per million tokens, as decimal text ("4", "0.10").

type Progress

type Progress func(text string)

Progress is told the answer as it is written: the text of the current call to the model so far. After tools run, the next call starts again from empty. The changes prepared (and any screen) arrive only with the finished Turn, written by Exhale.

type Proposal

type Proposal struct {
	ID          string        `json:"id"`
	Tool        string        `json:"tool" doc:"the use case that would run, e.g. create_subscription"`
	Title       domain.Text   `json:"title" doc:"what would happen, e.g. Add Netflix, as a translatable text"`
	Lines       []domain.Text `json:"lines" doc:"the details of the change, as translatable texts"`
	Destructive bool          `json:"destructive" doc:"it deletes or cancels"`
}

Proposal is a change the model prepared. Its title and lines are written by Exhale from the arguments and the dry run, never by the model, so the card the person confirms says what will really happen.

type Provider

type Provider interface {
	// Name is the provider id: "anthropic" or "openai".
	Name() string
	Complete(ctx context.Context, req Request) (Response, error)
}

Provider talks to one kind of model API.

type Request

type Request struct {
	System    string
	Messages  []Message
	Tools     []Tool
	MaxTokens int
}

Request is one call to a model.

type Response

type Response struct {
	Text      string
	ToolCalls []ToolCall
	Raw       json.RawMessage
	Stop      Stop
	Usage     Usage
	Model     string
}

Response is what the model answered: text, tool calls, or both.

type Review

type Review struct {
	ID      string          `json:"id" doc:"changes when another file replaces the review"`
	Channel string          `json:"channel" doc:"the chat it came from, e.g. telegram"`
	Result  importer.Result `json:"result"`
	// Outcomes is the dry run of importing Result.Items.
	Outcomes []app.ImportOutcome `json:"outcomes"`
	// Off are the lines the person unticked in the chat.
	Off       map[int]bool `json:"off,omitempty"`
	CreatedAt time.Time    `json:"createdAt"`
}

Review is a file read from a chat and waiting for the person to decide: the chat shows it with a button per line, and the web shows the same list (Settings → Data → Import) when it is too long for a chat. Each member has at most one; a new file replaces it. The file itself is not kept, only what was read from it.

type Role

type Role string

Role says who wrote a message.

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	// RoleTool carries the results of the tool calls of the assistant
	// message before it.
	RoleTool Role = "tool"
)

Roles of a conversation.

type Screen

type Screen struct {
	Kind          string                 `json:"kind" enum:"subscriptions,charges,total"`
	Subscriptions []app.SubscriptionView `json:"subscriptions,omitempty" doc:"kind subscriptions: in the order the model chose"`
	Charges       *app.ChargeList        `json:"charges,omitempty" doc:"kind charges: the period and its charges"`
	Total         *app.Breakdown         `json:"total,omitempty" doc:"kind total"`
	More          int                    `json:"more,omitempty" doc:"records left out because the screen is full"`
}

Screen is a view the assistant chose to show. Only the field of its kind is set.

type Settings

type Settings struct {
	Provider string `` /* 152-byte string literal not displayed */
	APIKey   string `json:"apiKey,omitempty" doc:"leave empty to keep the saved key"`
	Model    string `json:"model,omitempty" doc:"model id; required for openai, optional for anthropic"`
	BaseURL  string `` /* 131-byte string literal not displayed */
	Enabled  *bool  `json:"enabled,omitempty" doc:"on/off switch; on when omitted"`
	Price    *Price `json:"price,omitempty" doc:"the price of the model, to estimate costs; both empty forgets it. Omit to keep it"`
}

Settings is what the owner saves from the web.

type Status

type Status struct {
	Enabled      bool        `json:"enabled" doc:"the assistant answers: it is configured and switched on"`
	Configured   bool        `json:"configured" doc:"a provider, and what it needs, is set"`
	On           bool        `json:"on" doc:"the on/off switch, whatever the configuration"`
	Provider     string      `json:"provider,omitempty" enum:"anthropic,openai"`
	Model        string      `json:"model,omitempty"`
	BaseURL      string      `json:"baseUrl,omitempty"`
	HasKey       bool        `json:"hasKey" doc:"an API key is saved; it is never returned"`
	Locked       []string    `json:"locked" doc:"fields set by environment variables on the server, not editable here: provider, apiKey, model, baseUrl"`
	DefaultModel string      `json:"defaultModel" doc:"the model used with Anthropic when none is given"`
	Price        *Price      `json:"price,omitempty" doc:"the price used to estimate the cost of the current model; absent when it is not known"`
	PriceSource  string      `` /* 136-byte string literal not displayed */
	Month        *MonthUsage `json:"month,omitempty" doc:"what the assistant was asked this month"`
}

Status describes the assistant for the settings UI. It never carries the API key, only whether one is saved.

type Stop

type Stop string

Stop says why the model stopped writing.

const (
	StopEnd     Stop = "end"     // it finished its answer
	StopTools   Stop = "tools"   // it wants tools to run
	StopLength  Stop = "length"  // it hit the token limit
	StopRefused Stop = "refused" // the provider declined to answer
)

Reasons a model stops.

type Streamer

type Streamer interface {
	Stream(ctx context.Context, req Request, onText func(delta string)) (Response, error)
}

Streamer is a provider that can send its answer as it is written. onText is called with each piece of answer text (not reasoning, not tool arguments), in order.

type TermsField

type TermsField struct {
	Field   string `json:"field" enum:"noticeDays,cancelUrl,startedOn,trialEndsOn,account" doc:"the update_subscription field it fills"`
	Value   string `json:"value" doc:"the checked value; noticeDays as a whole number"`
	Current string `json:"current,omitempty" doc:"what Exhale has now, if anything"`
	Quote   string `json:"quote,omitempty" doc:"the words of the document it is based on, as the model reported them"`
}

TermsField is one contract detail found in a document.

func (TermsField) Update

Update is the update_subscription input that applies this one field.

type TermsProposal

type TermsProposal struct {
	SubscriptionID string       `json:"subscriptionId"`
	DocumentID     string       `json:"documentId"`
	Fields         []TermsField `json:"fields"`
	Notes          []string     `json:"notes"`
	Model          string       `json:"model"`
}

TermsProposal is what a document says about a subscription's contract, for the person to review. Nothing is saved.

type TestResult

type TestResult struct {
	OK      bool        `json:"ok"`
	Message domain.Text `json:"message" doc:"a translatable text"`
	Model   string      `json:"model,omitempty" doc:"the model that answered"`
}

TestResult is the outcome of the settings test.

type Tool

type Tool struct {
	Name        string
	Description string
	Schema      json.RawMessage // JSON Schema of the arguments
}

Tool is a use case offered to the model.

type ToolCall

type ToolCall struct {
	ID        string
	Name      string
	Arguments json.RawMessage
}

ToolCall is a use case the model wants to run.

type ToolResult

type ToolResult struct {
	CallID  string
	Name    string
	Content string
	IsError bool
}

ToolResult answers a ToolCall.

func LastToolResult

func LastToolResult(req Request) ToolResult

LastToolResult returns the newest tool result in a request ("" if none).

type Turn

type Turn struct {
	Text      string       `json:"text" doc:"the answer, plain text"`
	Proposals []Proposal   `json:"proposals" doc:"changes prepared for the person to confirm or discard; nothing is applied until they confirm"`
	Mentions  []Mention    `json:"mentions" doc:"subscriptions the answer talks about, for links"`
	Screens   []Screen     `json:"screens" doc:"screens the model chose to show; every name and number in them comes from the database"`
	Note      *domain.Text `json:"note,omitempty" doc:"why the answer may be incomplete, e.g. the step limit was reached; a translatable text"`
}

Turn is the assistant's answer to one message.

type Usage

type Usage struct {
	Input  int
	Output int
}

Usage counts the tokens of one call.

type UsageEntry

type UsageEntry struct {
	ID           string    `json:"id"`
	At           time.Time `json:"at"`
	Member       string    `json:"member" doc:"who asked"`
	Kind         string    `json:"kind" enum:"question,import"`
	Surface      string    `json:"surface" doc:"web, telegram, slack or import"`
	Provider     string    `json:"provider"`
	Model        string    `json:"model"`
	Rounds       int       `json:"rounds" doc:"calls to the model"`
	InputTokens  int       `json:"inputTokens"`
	OutputTokens int       `json:"outputTokens"`
	CostUSD      *string   `json:"costUsd,omitempty" doc:"estimated cost in US dollars; absent when the model's price is not known"`
	Failed       bool      `json:"failed"`
}

UsageEntry is one question (or import) in the activity timeline.

type UsageList

type UsageList struct {
	Entries []UsageEntry `json:"entries"`
}

UsageList is the newest entries first.

Jump to

Keyboard shortcuts

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