using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
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_choice → classify_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
/// can be swapped (e.g. to a cheaper model) without code changes.
///
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
{
private readonly HttpClient _http;
private readonly ClaudeOptions _options;
private readonly ILogger _logger;
public ClaudeExpenseAiService(
HttpClient http,
IOptions options,
AppDbContext db,
ILogger logger)
: base(db)
{
_http = http;
_options = options.Value;
_logger = logger;
}
protected override async Task CallModelAsync(string prompt, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
return null;
}
try
{
var payload = new
{
model = _options.Model,
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 = $"{_options.BaseUrl}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("x-api-key", _options.ApiKey);
request.Headers.Add("anthropic-version", _options.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; }
}
}