Extract for better separation of concerns: - Services/PdfToImageConverter.cs - PDF to image conversion using ImageMagick - Services/AIVisionClient.cs - OpenAI and Claude vision API clients - IAIVisionClient interface - OpenAIVisionClient, ClaudeVisionClient implementations AIReceiptParser now orchestrates using injected services. Adds proper logging for auto-mapping operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
220 lines
8.3 KiB
C#
220 lines
8.3 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace MoneyMap.Services
|
|
{
|
|
/// <summary>
|
|
/// Result of an AI vision API call.
|
|
/// </summary>
|
|
public class VisionApiResult
|
|
{
|
|
public bool IsSuccess { get; init; }
|
|
public string? Content { get; init; }
|
|
public string? ErrorMessage { get; init; }
|
|
|
|
public static VisionApiResult Success(string content) =>
|
|
new() { IsSuccess = true, Content = content };
|
|
|
|
public static VisionApiResult Failure(string error) =>
|
|
new() { IsSuccess = false, ErrorMessage = error };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client for making vision API calls to AI providers.
|
|
/// </summary>
|
|
public interface IAIVisionClient
|
|
{
|
|
Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model);
|
|
}
|
|
|
|
/// <summary>
|
|
/// OpenAI Vision API client.
|
|
/// </summary>
|
|
public class OpenAIVisionClient : IAIVisionClient
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<OpenAIVisionClient> _logger;
|
|
|
|
public OpenAIVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<OpenAIVisionClient> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
|
{
|
|
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
|
|
?? _configuration["OpenAI:ApiKey"];
|
|
|
|
if (string.IsNullOrWhiteSpace(apiKey))
|
|
return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json");
|
|
|
|
var requestBody = new
|
|
{
|
|
model = model,
|
|
messages = new[]
|
|
{
|
|
new
|
|
{
|
|
role = "user",
|
|
content = new object[]
|
|
{
|
|
new { type = "text", text = prompt },
|
|
new
|
|
{
|
|
type = "image_url",
|
|
image_url = new { url = $"data:{mediaType};base64,{base64Image}" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
max_tokens = 2000,
|
|
temperature = 0.1
|
|
};
|
|
|
|
try
|
|
{
|
|
_httpClient.DefaultRequestHeaders.Clear();
|
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
|
|
|
|
var json = JsonSerializer.Serialize(requestBody);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await _httpClient.PostAsync("https://api.openai.com/v1/chat/completions", content);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("OpenAI API error ({StatusCode}): {Error}", response.StatusCode, errorContent);
|
|
return VisionApiResult.Failure($"OpenAI API error ({response.StatusCode}): {errorContent}");
|
|
}
|
|
|
|
var responseJson = await response.Content.ReadAsStringAsync();
|
|
var responseObj = JsonSerializer.Deserialize<JsonElement>(responseJson);
|
|
|
|
var messageContent = responseObj
|
|
.GetProperty("choices")[0]
|
|
.GetProperty("message")
|
|
.GetProperty("content")
|
|
.GetString();
|
|
|
|
return VisionApiResult.Success(CleanJsonResponse(messageContent));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "OpenAI Vision API call failed: {Message}", ex.Message);
|
|
return VisionApiResult.Failure($"OpenAI API error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static string CleanJsonResponse(string? content)
|
|
{
|
|
var trimmed = content?.Trim() ?? "";
|
|
if (trimmed.StartsWith("```json"))
|
|
{
|
|
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim();
|
|
}
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anthropic Claude Vision API client.
|
|
/// </summary>
|
|
public class ClaudeVisionClient : IAIVisionClient
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<ClaudeVisionClient> _logger;
|
|
|
|
public ClaudeVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<ClaudeVisionClient> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
|
{
|
|
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
|
|
?? _configuration["Anthropic:ApiKey"];
|
|
|
|
if (string.IsNullOrWhiteSpace(apiKey))
|
|
return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json");
|
|
|
|
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 = prompt }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
try
|
|
{
|
|
_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();
|
|
_logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent);
|
|
return VisionApiResult.Failure($"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();
|
|
|
|
return VisionApiResult.Success(CleanJsonResponse(messageContent));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Claude Vision API call failed: {Message}", ex.Message);
|
|
return VisionApiResult.Failure($"Anthropic API error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static string CleanJsonResponse(string? content)
|
|
{
|
|
var trimmed = content?.Trim() ?? "";
|
|
if (trimmed.StartsWith("```json"))
|
|
{
|
|
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim();
|
|
}
|
|
return trimmed;
|
|
}
|
|
}
|
|
}
|