73077295a4
ci-cd-vm / ci-cd (push) Successful in 2m25s
Add an AI assist button to the Edit/New Group (大項) and Subcategory (小項) dialogs: the user enters a Chinese name, and the model refines the Chinese, translates it to English, and suggests the matching IRS Form 990 Part IX line. Suggestions surface in a confirm card; Apply fills the Chinese name, English name, and 990 line fields. Backend mirrors the existing expense-classification AI family but over the Form 990 line catalog: IExpenseCategoryAiService + base (catalog load, prompt, id validation) + Claude/Gemini providers + factory that picks the provider from ChurchProfile.AiProvider. New write-gated POST api/expense-categories/ai-suggest endpoint; sub-category requests pass the parent group + its 990 line to bias the choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
4.9 KiB
C#
125 lines
4.9 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using ROLAC.API.Data;
|
|
|
|
namespace ROLAC.API.Services.Ai;
|
|
|
|
/// <summary>
|
|
/// Refines, translates, and maps an expense category to a Form 990 line via the Anthropic Claude
|
|
/// Messages API. It forces a single tool call (<c>tool_choice</c> → <c>map_category</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="ExpenseCategoryAiServiceBase"/>; this class only owns the Claude HTTP call + parse.
|
|
/// </summary>
|
|
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<ClaudeExpenseCategoryAiService> _logger;
|
|
|
|
public ClaudeExpenseCategoryAiService(
|
|
HttpClient http,
|
|
IChurchAiConfigProvider config,
|
|
AppDbContext db,
|
|
ILogger<ClaudeExpenseCategoryAiService> logger)
|
|
: base(db)
|
|
{
|
|
_http = http;
|
|
_config = config;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task<ModelAnswer?> 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<ClaudeAnswer>(
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Shape of the map_category tool input the model fills in.</summary>
|
|
private sealed class ClaudeAnswer
|
|
{
|
|
public string? ChineseName { get; set; }
|
|
public string? EnglishName { get; set; }
|
|
public int? Form990LineId { get; set; }
|
|
public double Confidence { get; set; }
|
|
}
|
|
}
|