using System.Net.Http.Json; using System.Text.Json; using ROLAC.API.Data; namespace ROLAC.API.Services.Ai; /// /// Refines, translates, and maps an expense category to a Form 990 line via the Anthropic Claude /// Messages API. It forces a single tool call (tool_choicemap_category) 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. /// public sealed class ClaudeExpenseCategoryAiService : ExpenseCategoryAiServiceBase { 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 ClaudeExpenseCategoryAiService( 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; category AI assist is disabled."); return null; } try { var payload = new { model = cfg.ClaudeModel, max_tokens = 1024, tools = new[] { new { name = "map_category", description = "Record the refined Chinese name, English translation, and chosen Form 990 line id for the expense category.", input_schema = new { type = "object", properties = new { chineseName = new { type = "string" }, englishName = new { type = "string" }, form990LineId = new { type = "integer" }, confidence = new { type = "number" }, }, required = new[] { "chineseName", "englishName", "form990LineId", "confidence" }, }, }, }, tool_choice = new { type = "tool", name = "map_category" }, 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.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence); } _logger.LogWarning("Claude response contained no tool_use block."); return null; } catch (Exception ex) { _logger.LogError(ex, "Claude category AI assist failed."); return null; } } /// Shape of the map_category tool input the model fills in. private sealed class ClaudeAnswer { public string? ChineseName { get; set; } public string? EnglishName { get; set; } public int? Form990LineId { get; set; } public double Confidence { get; set; } } }