Feature: add Anthropic Claude model support for receipt parsing

Add Claude 3.5 Haiku and Sonnet as parsing options:
- Add Claude models to AI Model dropdown (Haiku for fast, Sonnet for best quality)
- Update OpenAIReceiptParser to detect provider based on model name (claude-* prefix)
- Add CallClaudeVisionAsync method using Anthropic Messages API
- Support ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey config
- Parse logs now correctly show "Anthropic" or "OpenAI" as provider
- Both providers use the same prompt template and return structure

Users can now choose from 4 models:
- GPT-4o Mini (fast & cheap)
- GPT-4o (smarter)
- Claude 3.5 Haiku (fast)
- Claude 3.5 Sonnet (best quality)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-19 16:15:25 -04:00
parent f09d19ec5c
commit f7c6b2691b
2 changed files with 117 additions and 13 deletions

View File

@@ -165,8 +165,14 @@
<div class="mb-2"> <div class="mb-2">
<label for="model" class="form-label small">AI Model</label> <label for="model" class="form-label small">AI Model</label>
<select name="model" id="model" class="form-select form-select-sm"> <select name="model" id="model" class="form-select form-select-sm">
<option value="gpt-4o-mini" selected>GPT-4o Mini (Fast & Cheap)</option> <optgroup label="OpenAI">
<option value="gpt-4o">GPT-4o (Smarter)</option> <option value="gpt-4o-mini" selected>GPT-4o Mini (Fast & Cheap)</option>
<option value="gpt-4o">GPT-4o (Smarter)</option>
</optgroup>
<optgroup label="Anthropic">
<option value="claude-3-5-haiku-20241022">Claude 3.5 Haiku (Fast)</option>
<option value="claude-3-5-sonnet-20241022">Claude 3.5 Sonnet (Best)</option>
</optgroup>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm w-100"> <button type="submit" class="btn btn-primary btn-sm w-100">

View File

@@ -47,25 +47,39 @@ namespace MoneyMap.Services
if (receipt == null) if (receipt == null)
return ReceiptParseResult.Failure("Receipt not found."); return ReceiptParseResult.Failure("Receipt not found.");
// Try environment variable first, then configuration // Default to gpt-4o-mini if no model specified
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") var selectedModel = model ?? "gpt-4o-mini";
?? _configuration["OpenAI:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey)) // Determine provider based on model name
return ReceiptParseResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json"); var isClaude = selectedModel.StartsWith("claude-");
var provider = isClaude ? "Anthropic" : "OpenAI";
// Get appropriate API key
string? apiKey;
if (isClaude)
{
apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
?? _configuration["Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
return ReceiptParseResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json");
}
else
{
apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? _configuration["OpenAI:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
return ReceiptParseResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json");
}
var filePath = Path.Combine(_environment.WebRootPath, receipt.StoragePath.Replace("/", Path.DirectorySeparatorChar.ToString())); var filePath = Path.Combine(_environment.WebRootPath, receipt.StoragePath.Replace("/", Path.DirectorySeparatorChar.ToString()));
if (!File.Exists(filePath)) if (!File.Exists(filePath))
return ReceiptParseResult.Failure("Receipt file not found on disk."); return ReceiptParseResult.Failure("Receipt file not found on disk.");
// Default to gpt-4o-mini if no model specified
var selectedModel = model ?? "gpt-4o-mini";
var parseLog = new ReceiptParseLog var parseLog = new ReceiptParseLog
{ {
ReceiptId = receiptId, ReceiptId = receiptId,
Provider = "OpenAI", Provider = provider,
Model = selectedModel, Model = selectedModel,
StartedAtUtc = DateTime.UtcNow, StartedAtUtc = DateTime.UtcNow,
Success = false Success = false
@@ -90,9 +104,11 @@ namespace MoneyMap.Services
mediaType = receipt.ContentType; mediaType = receipt.ContentType;
} }
// Call OpenAI Vision API with transaction name context // Call Vision API with transaction name context
var transactionName = receipt.Transaction?.Name; var transactionName = receipt.Transaction?.Name;
var parseData = await CallOpenAIVisionAsync(apiKey, base64Data, mediaType, selectedModel, transactionName); var parseData = isClaude
? await CallClaudeVisionAsync(apiKey, base64Data, mediaType, selectedModel, transactionName)
: await CallOpenAIVisionAsync(apiKey, base64Data, mediaType, selectedModel, transactionName);
// Update receipt with parsed data // Update receipt with parsed data
receipt.Merchant = parseData.Merchant; receipt.Merchant = parseData.Merchant;
@@ -297,6 +313,88 @@ namespace MoneyMap.Services
return parsedData ?? new ParsedReceiptData(); return parsedData ?? new ParsedReceiptData();
} }
private async Task<ParsedReceiptData> CallClaudeVisionAsync(string apiKey, string base64Image, string mediaType, string model, string? transactionName = null)
{
// Load the prompt template from file
var promptText = await LoadPromptTemplateAsync();
// Add transaction context if available
if (!string.IsNullOrWhiteSpace(transactionName))
{
promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear.";
}
promptText += "\n\nRespond ONLY with valid JSON, no other text.";
var requestBody = new
{
model = model,
max_tokens = 2000,
messages = new[]
{
new
{
role = "user",
content = new object[]
{
new
{
type = "image",
source = new
{
type = "base64",
media_type = mediaType,
data = base64Image
}
},
new
{
type = "text",
text = promptText
}
}
}
}
};
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey);
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new Exception($"Anthropic API error ({response.StatusCode}): {errorContent}");
}
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonElement>(responseJson);
var messageContent = responseObj
.GetProperty("content")[0]
.GetProperty("text")
.GetString();
// Clean up the response - remove markdown code blocks if present
messageContent = messageContent?.Trim();
if (messageContent?.StartsWith("```json") == true)
{
messageContent = messageContent.Replace("```json", "").Replace("```", "").Trim();
}
var parsedData = JsonSerializer.Deserialize<ParsedReceiptData>(messageContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return parsedData ?? new ParsedReceiptData();
}
} }
public class ParsedReceiptData public class ParsedReceiptData