// Package classify calls the Anthropic API to read an HSA receipt image and // suggest its patient, category, date, and amount. It builds the prompt from the // configured people and categories (see prompt.go) and forces a structured answer // via tool-use so the result is always valid JSON. The HTTP endpoint is injectable // so tests can run against a mock (or the cheapest model) instead of paying for Opus. package classify import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "time" "maisym.com/hsa/internal/config" ) // DefaultEndpoint is the live Anthropic messages API. const DefaultEndpoint = "https://api.anthropic.com/v1/messages" // Suggestion is the classifier's reading of a receipt. The normalized fields are // nil when the model could not determine them; Category falls back to the last // (most general) configured category rather than nil, since it is a closed set. // Raw* hold the literal text the model saw, for auditing misreads. type Suggestion struct { Person *string `json:"person"` // canonical person label, or nil Category string `json:"category"` // one of the configured category names Date *string `json:"date"` // YYYY-MM-DD, or nil Amount *string `json:"amount"` // plain number string e.g. "42.50", or nil RawName string `json:"raw_name"` RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` } // Classifier holds everything needed to classify a receipt. type Classifier struct { APIKey string Model string Endpoint string HTTP *http.Client Persons []config.Person Categories []config.Category } // New builds a Classifier for the live API. Persons and categories come from the // catalog; pass an empty model to use a sensible default. func New(apiKey, model string, persons []config.Person, categories []config.Category) *Classifier { if model == "" { model = "claude-opus-4-8" } return &Classifier{ APIKey: apiKey, Model: model, Endpoint: DefaultEndpoint, HTTP: &http.Client{Timeout: 60 * time.Second}, Persons: persons, Categories: categories, } } const toolName = "record_receipt" // --- request / response shapes --- type apiRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` Temperature float64 `json:"temperature"` System string `json:"system"` Tools []apiTool `json:"tools"` ToolChoice apiToolPick `json:"tool_choice"` Messages []apiMessage `json:"messages"` } type apiTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema map[string]any `json:"input_schema"` } type apiToolPick struct { Type string `json:"type"` Name string `json:"name"` } type apiMessage struct { Role string `json:"role"` Content []apiBlock `json:"content"` } type apiBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Source *apiSource `json:"source,omitempty"` } type apiSource struct { Type string `json:"type"` MediaType string `json:"media_type"` Data string `json:"data"` } type apiResponse struct { Content []struct { Type string `json:"type"` Name string `json:"name"` Input json.RawMessage `json:"input"` } `json:"content"` Error *struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } type toolInput struct { Person *string `json:"person"` Category *string `json:"category"` Date *string `json:"date"` Amount *string `json:"amount"` RawName string `json:"raw_name"` RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` } // Classify sends the receipt to the model and returns its normalized reading. // today is used so the date heuristics ("closest to today, never future") are // testable; pass time.Now(). func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) { system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02")) imgBlock := apiBlock{ Type: "image", Source: &apiSource{Type: "base64", MediaType: mimeType, Data: base64.StdEncoding.EncodeToString(image)}, } if mimeType == "application/pdf" { imgBlock.Type = "document" } reqBody := apiRequest{ Model: c.Model, MaxTokens: 1024, Temperature: 0, System: system, Tools: []apiTool{{Name: toolName, Description: "Record the extracted receipt fields.", InputSchema: c.inputSchema()}}, ToolChoice: apiToolPick{Type: "tool", Name: toolName}, Messages: []apiMessage{{ Role: "user", Content: []apiBlock{ imgBlock, {Type: "text", Text: "Read this receipt and call record_receipt."}, }, }}, } payload, err := json.Marshal(reqBody) if err != nil { return Suggestion{}, fmt.Errorf("marshal request: %w", err) } endpoint := c.Endpoint if endpoint == "" { endpoint = DefaultEndpoint } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return Suggestion{}, fmt.Errorf("build request: %w", err) } req.Header.Set("x-api-key", c.APIKey) req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("content-type", "application/json") httpClient := c.HTTP if httpClient == nil { httpClient = http.DefaultClient } resp, err := httpClient.Do(req) if err != nil { return Suggestion{}, fmt.Errorf("call anthropic: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return Suggestion{}, fmt.Errorf("read response: %w", err) } var ar apiResponse if err := json.Unmarshal(body, &ar); err != nil { return Suggestion{}, fmt.Errorf("parse response (status %d): %w", resp.StatusCode, err) } if ar.Error != nil { return Suggestion{}, fmt.Errorf("anthropic error: %s: %s", ar.Error.Type, ar.Error.Message) } if resp.StatusCode != http.StatusOK { return Suggestion{}, fmt.Errorf("anthropic status %d: %s", resp.StatusCode, string(body)) } for _, blk := range ar.Content { if blk.Type == "tool_use" && blk.Name == toolName { var in toolInput if err := json.Unmarshal(blk.Input, &in); err != nil { return Suggestion{}, fmt.Errorf("parse tool input: %w", err) } return c.normalize(in), nil } } return Suggestion{}, fmt.Errorf("no %s tool_use in response", toolName) } // inputSchema is the JSON Schema the model must fill. Enums constrain person and // category to the configured sets; null is allowed where a field may be unknown. func (c *Classifier) inputSchema() map[string]any { personEnum := append(allowedLabelsAny(c.Persons), nil) catEnum := make([]any, 0, len(c.Categories)) for _, cat := range c.Categories { catEnum = append(catEnum, cat.Name) } return map[string]any{ "type": "object", "properties": map[string]any{ "person": map[string]any{"type": []string{"string", "null"}, "enum": personEnum, "description": "Exact patient name, or null if absent/ambiguous."}, "category": map[string]any{"type": "string", "enum": catEnum, "description": "Best-fit category name."}, "date": map[string]any{"type": []string{"string", "null"}, "description": "Service date as YYYY-MM-DD, or null."}, "amount": map[string]any{"type": []string{"string", "null"}, "description": "Total paid as a plain number, or null."}, "raw_name": map[string]any{"type": "string", "description": "Literal name text read, or empty string."}, "raw_date": map[string]any{"type": "string", "description": "Literal date text read, or empty string."}, "raw_amount": map[string]any{"type": "string", "description": "Literal amount text read, or empty string."}, }, "required": []string{"person", "category", "date", "amount", "raw_name", "raw_date", "raw_amount"}, } } // allowedLabels here returns []any for the schema enum (string labels). func allowedLabelsAny(persons []config.Person) []any { labels := allowedLabels(persons) out := make([]any, len(labels)) for i, l := range labels { out[i] = l } return out } // normalize validates and cleans the raw tool output into a Suggestion. It guards // against the model returning a non-canonical person or category despite the enum. func (c *Classifier) normalize(in toolInput) Suggestion { s := Suggestion{ RawName: strings.TrimSpace(in.RawName), RawDate: strings.TrimSpace(in.RawDate), RawAmount: strings.TrimSpace(in.RawAmount), } // Person: keep only if it matches a canonical label exactly. if in.Person != nil { if p := strings.TrimSpace(*in.Person); p != "" && !isNullish(p) { for _, want := range c.Persons { if strings.EqualFold(p, want.Label()) { label := want.Label() s.Person = &label break } } } } // Category: must be a configured name; otherwise fall back to the last // (most general) category. s.Category = c.fallbackCategory() if in.Category != nil { got := strings.TrimSpace(*in.Category) for _, cat := range c.Categories { if strings.EqualFold(got, cat.Name) { s.Category = cat.Name break } } } if in.Date != nil { if d := strings.TrimSpace(*in.Date); d != "" && !isNullish(d) { s.Date = &d } } if in.Amount != nil { if a := strings.TrimSpace(*in.Amount); a != "" && !isNullish(a) { s.Amount = &a } } return s } func (c *Classifier) fallbackCategory() string { if len(c.Categories) == 0 { return "" } return c.Categories[len(c.Categories)-1].Name } // isNullish catches stringified nulls the model may emit despite the schema. func isNullish(s string) bool { switch strings.ToLower(s) { case "null", "none", "n/a", "na", "could not fetch", "unknown": return true } return false }