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
- Variables
- func Call(name string, args any) func(Request) (Response, error)
- func CallWith(name string, args func(Request) any) func(Request) (Response, error)
- func Explain(err error) domain.Text
- func Fail(err error) func(Request) (Response, error)
- func IsKind(err error, k Kind) bool
- func NewReviewID() string
- func Say(text string) func(Request) (Response, error)
- type Anthropic
- type Assistant
- func (a *Assistant) Ask(ctx context.Context, actor app.Actor, conv, text string) (Turn, error)
- func (a *Assistant) AskStream(ctx context.Context, actor app.Actor, conv, text string, progress Progress) (Turn, error)
- func (a *Assistant) Config(ctx context.Context) Config
- func (a *Assistant) Configure(ctx context.Context, in Settings) (Status, error)
- func (a *Assistant) Decide(ctx context.Context, actor app.Actor, id string, accept bool) (Outcome, error)
- func (a *Assistant) DropReview(ctx context.Context, actor app.Actor) error
- func (a *Assistant) Enabled(ctx context.Context) bool
- func (a *Assistant) LoadReview(ctx context.Context, actor app.Actor) (Review, error)
- func (a *Assistant) ReadImport(ctx context.Context, actor app.Actor, data []byte) (importer.Result, error)
- func (a *Assistant) ReadTerms(ctx context.Context, actor app.Actor, documentID string) (TermsProposal, error)
- func (a *Assistant) Remove(ctx context.Context) (Status, error)
- func (a *Assistant) Reset(ctx context.Context, actor app.Actor, conv string)
- func (a *Assistant) SaveReview(ctx context.Context, actor app.Actor, r Review) error
- func (a *Assistant) SetProvider(p Provider)
- func (a *Assistant) Status(ctx context.Context) Status
- func (a *Assistant) Test(ctx context.Context) TestResult
- func (a *Assistant) ThisMonth(ctx context.Context, actor app.Actor) MonthUsage
- func (a *Assistant) Tools() []Tool
- func (a *Assistant) Undo(ctx context.Context, actor app.Actor, id string) (Outcome, error)
- func (a *Assistant) Usage(ctx context.Context, actor app.Actor, owner bool, limit int) (UsageList, error)
- type Attachment
- type Config
- type Env
- type Error
- type Fake
- type Kind
- type Mention
- type Message
- type MonthUsage
- type OpenAI
- type Outcome
- type Price
- type Progress
- type Proposal
- type Provider
- type Request
- type Response
- type Review
- type Role
- type Screen
- type Settings
- type Status
- type Stop
- type Streamer
- type TermsField
- type TermsProposal
- type TestResult
- type Tool
- type ToolCall
- type ToolResult
- type Turn
- type Usage
- type UsageEntry
- type UsageList
Constants ¶
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.
const ( StatusPending = "pending" StatusApplied = "applied" StatusDiscarded = "discarded" StatusFailed = "failed" StatusUndone = "undone" )
Outcome statuses.
const ( ProviderAnthropic = "anthropic" ProviderOpenAI = "openai" // any OpenAI-compatible endpoint )
Providers the owner can pick.
const ( ScreenSubscriptions = "subscriptions" ScreenCharges = "charges" ScreenTotal = "total" )
Screen kinds.
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.
const ( // DefaultAnthropicModel is the model used when the owner does not pick // one. DefaultAnthropicModel = "claude-opus-5-5" )
Anthropic defaults.
const DefaultOpenAIURL = "https://api.openai.com/v1"
DefaultOpenAIURL is used when the owner picks the OpenAI-compatible provider without an address.
const ReviewTTL = 24 * time.Hour
ReviewTTL is how long a review waits.
Variables ¶
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.
var ErrNoReview = domain.NotFound("no import is waiting for review")
ErrNoReview is returned when the member has no review waiting.
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.
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.
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 CallWith ¶
CallWith is a step where the arguments depend on the request, e.g. an id that an earlier tool result returned.
func Explain ¶
Explain turns an error into one sentence for the person, in a form each edge renders in their language.
Types ¶
type Anthropic ¶
type Anthropic struct {
// contains filtered or unexported fields
}
Anthropic talks to the Claude Messages API.
func NewAnthropic ¶
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.
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 (*Assistant) Ask ¶
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 ¶
Config returns the effective configuration: what the web saved, with the environment's values on top.
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 ¶
DropReview forgets the member's review.
func (*Assistant) Enabled ¶
Enabled reports whether the assistant answers: configured and switched on. While it is false nothing is sent to any provider.
func (*Assistant) LoadReview ¶
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) SaveReview ¶
SaveReview keeps a review for the member, replacing any other.
func (*Assistant) SetProvider ¶
SetProvider makes the assistant use p whatever the settings say. Tests use it with the fake provider.
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.
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 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 ¶
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.
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.
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 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 ¶
NewOpenAI returns a client for a chat completions endpoint. key may be empty for local servers.
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 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 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 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 ¶
func (f TermsField) Update(id string) (app.UpdateSubscriptionInput, error)
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 ¶
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 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.