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:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user