diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs
index 86e817a..601e363 100644
--- a/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs
+++ b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs
@@ -18,6 +18,8 @@ public class ExpenseAiAssistRequest
public class ExpenseAiSuggestion
{
public string? EnglishDescription { get; set; }
+ /// Typo-corrected, refined Traditional Chinese description.
+ public string? ChineseDescription { get; set; }
public int? GroupId { get; set; }
public int? SubCategoryId { get; set; }
/// Bilingual label of the suggested group, e.g. "Consumables / 消耗品".
diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs
index 3d15ab6..359a3ac 100644
--- a/API/ROLAC.API/Program.cs
+++ b/API/ROLAC.API/Program.cs
@@ -179,11 +179,25 @@ builder.Services.AddScoped();
-// ── AI assist (Google Gemini) ──────────────────────────────────────────────
-// Backend proxy for expense translation + category suggestion; the API key stays server-side.
+// ── AI assist (expense translation + category suggestion) ──────────────────
+// Backend proxy so the API key stays server-side. Two interchangeable providers (Claude / Gemini)
+// implement IExpenseAiService; "Ai:Provider" selects which one is bound (default Claude).
builder.Services.Configure(config.GetSection("Gemini"));
-builder.Services.AddHttpClient();
+builder.Services.Configure(config.GetSection("Claude"));
+builder.Services.AddHttpClient();
+builder.Services.AddHttpClient();
+
+var aiProvider = config["Ai:Provider"] ?? "Claude";
+if (aiProvider.Equals("Gemini", StringComparison.OrdinalIgnoreCase))
+{
+ builder.Services.AddScoped(
+ sp => sp.GetRequiredService());
+}
+else
+{
+ builder.Services.AddScoped(
+ sp => sp.GetRequiredService());
+}
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
diff --git a/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs b/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs
new file mode 100644
index 0000000..21ee1d3
--- /dev/null
+++ b/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs
@@ -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;
+
+///
+/// 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; }
+ }
+}
diff --git a/API/ROLAC.API/Services/Ai/ClaudeOptions.cs b/API/ROLAC.API/Services/Ai/ClaudeOptions.cs
new file mode 100644
index 0000000..20b9b2d
--- /dev/null
+++ b/API/ROLAC.API/Services/Ai/ClaudeOptions.cs
@@ -0,0 +1,11 @@
+namespace ROLAC.API.Services.Ai;
+
+/// Anthropic Claude API settings (bound from the "Claude" config section).
+public sealed class ClaudeOptions
+{
+ /// API key sent as the x-api-key header. Keep out of source control.
+ public string ApiKey { get; set; } = "";
+ public string Model { get; set; } = "claude-opus-4-8";
+ public string BaseUrl { get; set; } = "https://api.anthropic.com/v1";
+ public string AnthropicVersion { get; set; } = "2023-06-01";
+}
diff --git a/API/ROLAC.API/Services/Ai/ExpenseAiServiceBase.cs b/API/ROLAC.API/Services/Ai/ExpenseAiServiceBase.cs
new file mode 100644
index 0000000..0970a76
--- /dev/null
+++ b/API/ROLAC.API/Services/Ai/ExpenseAiServiceBase.cs
@@ -0,0 +1,119 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ROLAC.API.Data;
+using ROLAC.API.DTOs.Expense;
+
+namespace ROLAC.API.Services.Ai;
+
+///
+/// Provider-independent expense-AI logic: loads the active category catalog, builds the
+/// classification prompt, and validates the model's chosen ids against that catalog. Concrete
+/// providers (Gemini, Claude) only implement — the HTTP call plus
+/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
+///
+public abstract class ExpenseAiServiceBase : IExpenseAiService
+{
+ private readonly AppDbContext _db;
+
+ protected ExpenseAiServiceBase(AppDbContext db) => _db = db;
+
+ /// One sub-category in the catalog passed to the model.
+ protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);
+
+ /// One major category (with its sub-categories) in the catalog passed to the model.
+ protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList Subs);
+
+ /// The model's raw answer, before its ids are validated against the catalog.
+ protected sealed record ModelAnswer(
+ string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);
+
+ public async Task SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
+ {
+ var catalog = await LoadCatalogAsync(ct);
+ var prompt = BuildPrompt(chineseText, amount, catalog);
+
+ var answer = await CallModelAsync(prompt, ct);
+ if (answer is null) return new ExpenseAiSuggestion();
+
+ return BuildSuggestion(answer, catalog);
+ }
+
+ ///
+ /// Call the provider's API with and return its parsed answer, or null
+ /// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
+ ///
+ protected abstract Task CallModelAsync(string prompt, CancellationToken ct);
+
+ private async Task> LoadCatalogAsync(CancellationToken ct)
+ {
+ return await _db.ExpenseCategoryGroups
+ .AsNoTracking()
+ .Where(group => group.IsActive)
+ .OrderBy(group => group.SortOrder)
+ .Select(group => new CatalogGroup(
+ group.Id,
+ group.Name_en,
+ group.Name_zh,
+ group.SubCategories
+ .Where(sub => sub.IsActive)
+ .OrderBy(sub => sub.SortOrder)
+ .Select(sub => new CatalogSub(sub.Id, sub.Name_en, sub.Name_zh))
+ .ToList()))
+ .ToListAsync(ct);
+ }
+
+ private static string BuildPrompt(string chineseText, decimal amount, List catalog)
+ {
+ var catalogJson = JsonSerializer.Serialize(catalog);
+ return
+ "You are a bookkeeping assistant for a church. Given an expense description (often in " +
+ "Traditional Chinese) and its amount, do three things:\n" +
+ "1. Correct any typos in the description and refine it into natural Traditional Chinese — " +
+ "return it as chineseDescription.\n" +
+ "2. Translate that into concise, natural accounting English (a short noun phrase, not a " +
+ "full sentence) — return it as englishDescription.\n" +
+ "3. Choose the single best matching major category (group) and sub-category from the catalog " +
+ "below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
+ "subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
+ "\"Other / 其他\" option and lower your confidence.\n\n" +
+ $"Expense description: {chineseText}\n" +
+ $"Amount: {amount}\n\n" +
+ $"Category catalog (JSON; each group has an Id, English/Chinese names, and its Subs):\n{catalogJson}";
+ }
+
+ private static ExpenseAiSuggestion BuildSuggestion(ModelAnswer answer, List catalog)
+ {
+ var suggestion = new ExpenseAiSuggestion
+ {
+ EnglishDescription = string.IsNullOrWhiteSpace(answer.EnglishDescription)
+ ? null
+ : answer.EnglishDescription.Trim(),
+ ChineseDescription = string.IsNullOrWhiteSpace(answer.ChineseDescription)
+ ? null
+ : answer.ChineseDescription.Trim(),
+ Confidence = answer.Confidence,
+ };
+
+ // Re-validate the returned ids against the catalog; drop anything that doesn't line up
+ // (defends against a hallucinated id, or a sub-category that doesn't belong to the group).
+ var group = catalog.FirstOrDefault(candidate => candidate.Id == answer.GroupId);
+ if (group is not null)
+ {
+ suggestion.GroupId = group.Id;
+ suggestion.GroupLabel = Label(group.NameEn, group.NameZh);
+
+ var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == answer.SubCategoryId);
+ if (sub is not null)
+ {
+ suggestion.SubCategoryId = sub.Id;
+ suggestion.SubLabel = Label(sub.NameEn, sub.NameZh);
+ }
+ }
+
+ return suggestion;
+ }
+
+ /// Mirror the frontend's bilingual() convention: "English / 中文" (or just English).
+ private static string Label(string nameEn, string? nameZh)
+ => string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
+}
diff --git a/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs
index e2e9418..e7f1c7b 100644
--- a/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs
+++ b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs
@@ -1,23 +1,19 @@
using System.Net.Http.Json;
using System.Text.Json;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ROLAC.API.Data;
-using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
///
-/// Calls the Google Gemini generateContent API to translate an expense description and
-/// classify it into the church's existing expense category catalog (大項 / 系項). The full active
-/// catalog is sent in the prompt so the model can only choose from real ids; any id it returns is
-/// re-validated against the catalog before being surfaced, so a hallucinated id is dropped, not echoed.
+/// Translates and classifies an expense via the Google Gemini generateContent API, using
+/// Gemini's structured-output mode (responseSchema). The catalog, prompt, and id validation
+/// come from ; this class only owns the Gemini HTTP call + parse.
///
-public sealed class GeminiExpenseAiService : IExpenseAiService
+public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
{
private readonly HttpClient _http;
private readonly GeminiOptions _options;
- private readonly AppDbContext _db;
private readonly ILogger _logger;
public GeminiExpenseAiService(
@@ -25,57 +21,23 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
IOptions options,
AppDbContext db,
ILogger logger)
+ : base(db)
{
_http = http;
_options = options.Value;
- _db = db;
_logger = logger;
}
- public async Task SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
+ protected override async Task CallModelAsync(string prompt, CancellationToken ct)
{
- // Load the active catalog: the allow-list the model must classify into.
- var groups = await _db.ExpenseCategoryGroups
- .AsNoTracking()
- .Where(group => group.IsActive)
- .OrderBy(group => group.SortOrder)
- .Select(group => new
- {
- group.Id,
- group.Name_en,
- group.Name_zh,
- Subs = group.SubCategories
- .Where(sub => sub.IsActive)
- .OrderBy(sub => sub.SortOrder)
- .Select(sub => new { sub.Id, sub.Name_en, sub.Name_zh })
- .ToList(),
- })
- .ToListAsync(ct);
-
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
- return new ExpenseAiSuggestion();
+ return null;
}
try
{
- var catalogJson = JsonSerializer.Serialize(groups);
- var prompt =
- "You are a bookkeeping assistant for a church. Given an expense description (often in " +
- "Traditional Chinese) and its amount, do two things:\n" +
- "1. Translate the description into concise, natural accounting English (a short noun phrase, " +
- "not a full sentence).\n" +
- "2. Choose the single best matching major category (group) and sub-category from the catalog " +
- "below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
- "subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
- "\"Other / 其他\" option and lower your confidence.\n\n" +
- $"Expense description: {chineseText}\n" +
- $"Amount: {amount}\n\n" +
- $"Category catalog (JSON; each group has an id, English/Chinese names, and its sub-categories):\n{catalogJson}\n\n" +
- "Respond with JSON: englishDescription (string), groupId (integer), subCategoryId (integer), " +
- "confidence (number 0..1).";
-
var payload = new
{
contents = new[]
@@ -90,12 +52,13 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
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[] { "englishDescription", "groupId", "subCategoryId", "confidence" },
+ required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
},
},
};
@@ -112,7 +75,7 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
- return new ExpenseAiSuggestion();
+ return null;
}
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
@@ -127,53 +90,27 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("Gemini response contained no text part.");
- return new ExpenseAiSuggestion();
+ return null;
}
var parsed = JsonSerializer.Deserialize(
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
- if (parsed is null) return new ExpenseAiSuggestion();
+ if (parsed is null) return null;
- var suggestion = new ExpenseAiSuggestion
- {
- EnglishDescription = string.IsNullOrWhiteSpace(parsed.EnglishDescription)
- ? null
- : parsed.EnglishDescription.Trim(),
- Confidence = parsed.Confidence,
- };
-
- // Re-validate the returned ids against the catalog; drop anything that doesn't line up.
- var group = groups.FirstOrDefault(candidate => candidate.Id == parsed.GroupId);
- if (group is not null)
- {
- suggestion.GroupId = group.Id;
- suggestion.GroupLabel = Label(group.Name_en, group.Name_zh);
-
- var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == parsed.SubCategoryId);
- if (sub is not null)
- {
- suggestion.SubCategoryId = sub.Id;
- suggestion.SubLabel = Label(sub.Name_en, sub.Name_zh);
- }
- }
-
- return suggestion;
+ return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Expense AI assist failed.");
- return new ExpenseAiSuggestion();
+ _logger.LogError(ex, "Gemini expense AI assist failed.");
+ return null;
}
}
- /// Mirror the frontend's bilingual() convention: "English / 中文" (or just English).
- private static string Label(string nameEn, string? nameZh)
- => string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
-
- /// Shape of the model's JSON answer (constrained by responseSchema).
+ /// Shape of Gemini's JSON answer (constrained by responseSchema).
private sealed class GeminiAnswer
{
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; }
diff --git a/API/ROLAC.API/Services/Ai/GeminiOptions.cs b/API/ROLAC.API/Services/Ai/GeminiOptions.cs
index 1afa2d9..da0fa2e 100644
--- a/API/ROLAC.API/Services/Ai/GeminiOptions.cs
+++ b/API/ROLAC.API/Services/Ai/GeminiOptions.cs
@@ -5,6 +5,6 @@ public sealed class GeminiOptions
{
/// API key sent as the X-goog-api-key header. Keep out of source control.
public string ApiKey { get; set; } = "";
- public string Model { get; set; } = "gemini-2.5-flash";
+ public string Model { get; set; } = "gemini-2.5-flash-lite";
public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta";
}
diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json
index a92dd22..cdb7947 100644
--- a/API/ROLAC.API/appsettings.json
+++ b/API/ROLAC.API/appsettings.json
@@ -44,7 +44,16 @@
},
"Gemini": {
"ApiKey": "",
- "Model": "gemini-flash-latest",
+ "Model": "gemini-2.5-flash-lite",
"BaseUrl": "https://generativelanguage.googleapis.com/v1beta"
+ },
+ "Claude": {
+ "ApiKey": "",
+ "Model": "claude-haiku-4-5-20251001",
+ "BaseUrl": "https://api.anthropic.com/v1",
+ "AnthropicVersion": "2023-06-01"
+ },
+ "Ai": {
+ "Provider": "Claude"
}
}
diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html
index 54ff5bf..0d85c84 100644
--- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html
+++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html
@@ -1,4 +1,5 @@
-
+