hsa-app/internal/classify/classify.go
Jean-Michel Tremblay c715c8e0c0
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00

334 lines
10 KiB
Go

// 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"`
// Usage is the token accounting for this call, used to compute its cost.
Usage Usage
}
// 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 = config.DefaultClassifyModel
}
return &Classifier{
APIKey: apiKey,
Model: model,
Endpoint: DefaultEndpoint,
HTTP: &http.Client{Timeout: 60 * time.Second},
Persons: persons,
Categories: categories,
}
}
// PromptPreview renders the exact system prompt that would be sent today with the
// given notes, for the read-only view on the AI tab.
func (c *Classifier) PromptPreview(today time.Time, notes []string) string {
return BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
}
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"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadTokens int `json:"cache_read_input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
} `json:"usage"`
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, notes []string) (Suggestion, error) {
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
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)
}
sug := c.normalize(in)
if ar.Usage != nil {
sug.Usage = Usage{
InputTokens: ar.Usage.InputTokens,
OutputTokens: ar.Usage.OutputTokens,
CacheReadTokens: ar.Usage.CacheReadTokens,
CacheCreationTokens: ar.Usage.CacheCreationTokens,
}
}
return sug, 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
}