311 lines
9.6 KiB
Go
311 lines
9.6 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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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
|
||
|
|
}
|