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_choiceclassify_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 @@ - +
@@ -14,7 +15,8 @@
- + +
@@ -72,6 +78,11 @@
明細 {{ i + 1 }} / Item {{ i + 1 }} +
@@ -103,6 +114,34 @@ + + +
+ +
+ + +
+
AI 建議 / Suggestion
+
+ 說明 / Description: + {{ lineSuggestedDescription(line) }} +
+
+ 分類 / Category: + {{ line.aiSuggestion?.groupLabel }} → + {{ line.aiSuggestion?.subLabel }} +
+
信心 / Confidence: {{ (line.aiSuggestion?.confidence ?? 0) * 100 | + number:'1.0-0' }}%
+
+ + +
+
@@ -144,11 +183,11 @@ 收據預覽 / Receipt
- + {{ receiptZoom * 100 | number:'1.0-0' }}% - +
diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts index b81c671..3703f88 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts @@ -38,6 +38,9 @@ interface ExpenseLineForm { * null = inherit the ministry default. Kept here so existing overrides survive an edit. */ functionalClass: FunctionalClass | null; subs: ExpenseSubCategoryDto[]; + /** Per-line AI assist state (suggest & confirm), independent of the header assist. */ + aiLoading?: boolean; + aiSuggestion?: ExpenseAiSuggestion | null; } @Component({ @@ -189,17 +192,31 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy { /** True once a suggestion offers at least a translation or a category to apply. */ get hasAiSuggestion(): boolean { const s = this.aiSuggestion; - return !!s && (!!s.englishDescription || s.groupId != null); + return !!s && (!!this.aiSuggestedDescription || s.groupId != null); + } + + /** Combine a suggestion's two halves into the "English / 中文" string that Apply writes. */ + private combineDescription(suggestion: ExpenseAiSuggestion | null | undefined): string { + if (!suggestion) return ''; + const en = suggestion.englishDescription?.trim() ?? ''; + const zh = suggestion.chineseDescription?.trim() ?? ''; + if (en && zh) return `${en} / ${zh}`; + return en || zh; + } + + /** The description that the header Apply will write. */ + get aiSuggestedDescription(): string { + return this.combineDescription(this.aiSuggestion); } /** - * Apply the suggestion: replace the description with the English translation and set the first - * line's category/sub. Most expenses are single-line; multi-line users adjust the rest by hand. + * Apply the suggestion: set the description to "English / 中文" and set the first line's + * category/sub. Most expenses are single-line; multi-line users adjust the rest by hand. */ applyAiSuggestion(): void { const suggestion = this.aiSuggestion; if (!suggestion) return; - if (suggestion.englishDescription) this.form.description = suggestion.englishDescription; + if (this.aiSuggestedDescription) this.form.description = this.aiSuggestedDescription; if (suggestion.groupId != null) { const firstLine = this.lines[0]; firstLine.categoryGroupId = suggestion.groupId; @@ -212,6 +229,46 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy { dismissAiSuggestion(): void { this.aiSuggestion = null; } + // ── Per-line AI assist ────────────────────────────────────────────────────── + /** Ask the AI to translate this line's own note and suggest its category, using the line's amount. */ + requestLineAiAssist(line: ExpenseLineForm): void { + const text = line.description.trim(); + if (!text || line.aiLoading) return; + line.aiLoading = true; + line.aiSuggestion = null; + this.aiApi.assist(text, line.amount).subscribe({ + next: suggestion => { line.aiSuggestion = suggestion; line.aiLoading = false; }, + error: () => { line.aiLoading = false; }, + }); + } + + /** The description that this line's Apply will write: "English / 中文". */ + lineSuggestedDescription(line: ExpenseLineForm): string { + return this.combineDescription(line.aiSuggestion); + } + + /** True once a line suggestion offers a translation or a category to apply. */ + hasLineSuggestion(line: ExpenseLineForm): boolean { + return !!line.aiSuggestion && (!!this.lineSuggestedDescription(line) || line.aiSuggestion.groupId != null); + } + + /** Apply this line's suggestion to itself: set its description (bilingual) and category/sub. */ + applyLineAiSuggestion(line: ExpenseLineForm): void { + const suggestion = line.aiSuggestion; + if (!suggestion) return; + const description = this.lineSuggestedDescription(line); + if (description) line.description = description; + if (suggestion.groupId != null) { + line.categoryGroupId = suggestion.groupId; + // Populate the sub-category list for the chosen group, then select the suggested sub. + this.onLineGroupChange(line, suggestion.groupId); + if (suggestion.subCategoryId != null) line.subCategoryId = suggestion.subCategoryId; + } + line.aiSuggestion = null; + } + + dismissLineAiSuggestion(line: ExpenseLineForm): void { line.aiSuggestion = null; } + addLine(): void { this.lines.push(this.emptyLine()); } removeLine(index: number): void { diff --git a/APP/src/app/features/expense/models/expense.model.ts b/APP/src/app/features/expense/models/expense.model.ts index e97069b..d0efdaa 100644 --- a/APP/src/app/features/expense/models/expense.model.ts +++ b/APP/src/app/features/expense/models/expense.model.ts @@ -36,6 +36,7 @@ export interface ExpenseDto extends ExpenseListItemDto { /** AI assist suggestion: English translation + a proposed major/sub category (null when unclassified). */ export interface ExpenseAiSuggestion { englishDescription: string | null; + chineseDescription: string | null; groupId: number | null; subCategoryId: number | null; groupLabel: string | null;