WIP
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.Data;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Translates and classifies an expense via the Anthropic Claude Messages API. It forces a single
|
||||
/// tool call (<c>tool_choice</c> → <c>classify_expense</c>) whose <c>input_schema</c> matches our
|
||||
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
|
||||
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
|
||||
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
|
||||
/// <see cref="ClaudeOptions.Model"/> can be swapped (e.g. to a cheaper model) without code changes.
|
||||
/// </summary>
|
||||
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ClaudeOptions _options;
|
||||
private readonly ILogger<ClaudeExpenseAiService> _logger;
|
||||
|
||||
public ClaudeExpenseAiService(
|
||||
HttpClient http,
|
||||
IOptions<ClaudeOptions> options,
|
||||
AppDbContext db,
|
||||
ILogger<ClaudeExpenseAiService> logger)
|
||||
: base(db)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task<ModelAnswer?> 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<ClaudeAnswer>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shape of the classify_expense tool input the model fills in.</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user