using System.Net.Http.Json; using System.Text.Json; using ROLAC.API.Data; namespace ROLAC.API.Services.Ai; /// /// Translates and classifies an expense via the Anthropic Claude Messages API. It forces a single /// tool call (tool_choiceclassify_expense) whose input_schema matches our /// answer shape, so the model returns structured JSON in a tool_use block. The catalog, /// prompt, and id validation come from ; this class only owns the /// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured /// model can be swapped (e.g. to a cheaper model) without code changes. /// public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase { private const string BaseUrl = "https://api.anthropic.com/v1"; private const string AnthropicVersion = "2023-06-01"; private readonly HttpClient _http; private readonly IChurchAiConfigProvider _config; private readonly ILogger _logger; public ClaudeExpenseAiService( HttpClient http, IChurchAiConfigProvider config, AppDbContext db, ILogger logger) : base(db) { _http = http; _config = config; _logger = logger; } protected override async Task CallModelAsync(string prompt, CancellationToken ct) { var cfg = await _config.GetAsync(ct); if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey)) { _logger.LogWarning("Claude API key is not configured; expense AI assist is disabled."); return null; } try { var payload = new { model = cfg.ClaudeModel, max_tokens = 1024, tools = new[] { new { name = "classify_expense", description = "Record the English translation and the chosen expense category ids for the expense.", input_schema = new { type = "object", properties = new { chineseDescription = new { type = "string" }, englishDescription = new { type = "string" }, groupId = new { type = "integer" }, subCategoryId = new { type = "integer" }, confidence = new { type = "number" }, }, required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" }, }, }, }, tool_choice = new { type = "tool", name = "classify_expense" }, messages = new[] { new { role = "user", content = prompt }, }, }; var url = $"{BaseUrl}/messages"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload), }; request.Headers.Add("x-api-key", cfg.ClaudeApiKey); request.Headers.Add("anthropic-version", AnthropicVersion); using var response = await _http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct); _logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body); return null; } // The forced tool call lands in content[] as a tool_use block; its `input` is our object. using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct)); foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray()) { if (block.GetProperty("type").GetString() != "tool_use") continue; var parsed = block.GetProperty("input").Deserialize( new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (parsed is null) return null; return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence); } _logger.LogWarning("Claude response contained no tool_use block."); return null; } catch (Exception ex) { _logger.LogError(ex, "Claude expense AI assist failed."); return null; } } /// Shape of the classify_expense tool input the model fills in. private sealed class ClaudeAnswer { public string? EnglishDescription { get; set; } public string? ChineseDescription { get; set; } public int GroupId { get; set; } public int SubCategoryId { get; set; } public double Confidence { get; set; } } }