feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s
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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseCategoryAiServiceFactoryTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryAiServiceFactory Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var cfg = new ChurchAiConfigProvider(db);
|
||||||
|
var claude = new ClaudeExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseCategoryAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseCategoryAiService>.Instance);
|
||||||
|
return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Claude_by_default()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<ClaudeExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Gemini_when_selected()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<GeminiExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using ROLAC.API.Authorization;
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
|
|||||||
public class ExpenseCategoriesController : ControllerBase
|
public class ExpenseCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseCategoryService _svc;
|
private readonly IExpenseCategoryService _svc;
|
||||||
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
|
private readonly IExpenseCategoryAiServiceFactory _aiFactory;
|
||||||
|
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_aiFactory = aiFactory;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
|
||||||
|
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
|
||||||
|
[HttpPost("ai-suggest")]
|
||||||
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
|
||||||
|
return BadRequest("A name is required.");
|
||||||
|
|
||||||
|
var svc = await _aiFactory.ResolveAsync(ct);
|
||||||
|
return Ok(await svc.SuggestAsync(r, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("groups")]
|
[HttpPost("groups")]
|
||||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
|
|||||||
@@ -29,3 +29,37 @@ public class ExpenseAiSuggestion
|
|||||||
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
|
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
|
||||||
public double Confidence { get; set; }
|
public double Confidence { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
|
||||||
|
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseCategoryAiRequest
|
||||||
|
{
|
||||||
|
/// <summary>The user-typed Chinese name (the primary input).</summary>
|
||||||
|
public string Name_zh { get; set; } = "";
|
||||||
|
/// <summary>The English name, if already typed (extra context for the model).</summary>
|
||||||
|
public string? Name_en { get; set; }
|
||||||
|
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
|
||||||
|
public string Level { get; set; } = "group";
|
||||||
|
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
|
||||||
|
public string? ParentGroupName { get; set; }
|
||||||
|
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
|
||||||
|
public int? ParentForm990LineId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
|
||||||
|
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
|
||||||
|
/// </summary>
|
||||||
|
public class CategoryAiSuggestion
|
||||||
|
{
|
||||||
|
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
|
||||||
|
public string? Form990LineLabel { get; set; }
|
||||||
|
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
|
|||||||
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
|
||||||
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
|
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
|
||||||
|
|
||||||
|
// Category-mapping AI (define a 大項/小項: refine name + translate + suggest Form 990 line).
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseCategoryAiService>();
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseCategoryAiService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseCategoryAiServiceFactory,
|
||||||
|
ROLAC.API.Services.Ai.ExpenseCategoryAiServiceFactory>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configurable role-based permissions (RBAC matrix)
|
// Configurable role-based permissions (RBAC matrix)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider-independent category-AI logic: loads the active Form 990 line catalog, builds the
|
||||||
|
/// mapping prompt, and validates the model's chosen line id against that catalog. Concrete providers
|
||||||
|
/// (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus response parsing —
|
||||||
|
/// so the catalog/prompt/validation code lives in exactly one place. Mirrors
|
||||||
|
/// <see cref="ExpenseAiServiceBase"/>, which does the same for the expense-entry classification task.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
/// <summary>One Form 990 line in the catalog passed to the model.</summary>
|
||||||
|
protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh);
|
||||||
|
|
||||||
|
/// <summary>The model's raw answer, before its line id is validated against the catalog.</summary>
|
||||||
|
protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence);
|
||||||
|
|
||||||
|
public async Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var catalog = await LoadCatalogAsync(ct);
|
||||||
|
var prompt = BuildPrompt(request, catalog);
|
||||||
|
|
||||||
|
var answer = await CallModelAsync(prompt, ct);
|
||||||
|
if (answer is null) return new CategoryAiSuggestion();
|
||||||
|
|
||||||
|
return BuildSuggestion(answer, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
|
||||||
|
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
|
||||||
|
|
||||||
|
private async Task<List<CatalogLine>> LoadCatalogAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _db.Form990ExpenseLines
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(line => line.IsActive)
|
||||||
|
.OrderBy(line => line.SortOrder)
|
||||||
|
.Select(line => new CatalogLine(line.Id, line.LineCode, line.Name_en, line.Name_zh))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(ExpenseCategoryAiRequest request, List<CatalogLine> catalog)
|
||||||
|
{
|
||||||
|
var catalogJson = JsonSerializer.Serialize(catalog);
|
||||||
|
var levelLabel = request.Level.Equals("sub", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "sub-category (小項)"
|
||||||
|
: "major category (大項)";
|
||||||
|
|
||||||
|
var context = new StringBuilder();
|
||||||
|
context.Append($"This is an expense {levelLabel} in a church's bookkeeping chart of accounts.\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name_zh))
|
||||||
|
context.Append($"Chinese name entered: {request.Name_zh}\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name_en))
|
||||||
|
context.Append($"English name entered: {request.Name_en}\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ParentGroupName))
|
||||||
|
context.Append($"It belongs under the parent major category: {request.ParentGroupName}\n");
|
||||||
|
if (request.ParentForm990LineId is int parentLineId)
|
||||||
|
context.Append(
|
||||||
|
$"The parent major category is mapped to Form 990 line id {parentLineId}; prefer a consistent " +
|
||||||
|
"choice unless a more specific line clearly fits this sub-category.\n");
|
||||||
|
|
||||||
|
return
|
||||||
|
"You are a bookkeeping assistant for a church mapping its expense categories to the IRS Form 990 " +
|
||||||
|
"Part IX (Statement of Functional Expenses) lines. Given an expense category name (often in " +
|
||||||
|
"Traditional Chinese), do three things:\n" +
|
||||||
|
"1. Correct any typos in the name and refine it into natural Traditional Chinese — return it as " +
|
||||||
|
"chineseName.\n" +
|
||||||
|
"2. Translate that into a concise, natural accounting English noun phrase (not a full sentence) — " +
|
||||||
|
"return it as englishName.\n" +
|
||||||
|
"3. Choose the single best matching Form 990 line from the catalog below. You MUST pick a " +
|
||||||
|
"form990LineId that appears in the catalog. If nothing fits well, choose the closest general line " +
|
||||||
|
"(e.g. an \"Other expenses\" line) and lower your confidence.\n\n" +
|
||||||
|
context +
|
||||||
|
"\n" +
|
||||||
|
$"Form 990 line catalog (JSON; each line has an Id, LineCode, and English/Chinese names):\n{catalogJson}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CategoryAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogLine> catalog)
|
||||||
|
{
|
||||||
|
var suggestion = new CategoryAiSuggestion
|
||||||
|
{
|
||||||
|
ChineseName = string.IsNullOrWhiteSpace(answer.ChineseName) ? null : answer.ChineseName.Trim(),
|
||||||
|
EnglishName = string.IsNullOrWhiteSpace(answer.EnglishName) ? null : answer.EnglishName.Trim(),
|
||||||
|
Confidence = answer.Confidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-validate the returned id against the catalog; drop a hallucinated id rather than returning it.
|
||||||
|
var line = catalog.FirstOrDefault(candidate => candidate.Id == answer.Form990LineId);
|
||||||
|
if (line is not null)
|
||||||
|
{
|
||||||
|
suggestion.Form990LineId = line.Id;
|
||||||
|
suggestion.Form990LineLabel = Label(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English").</summary>
|
||||||
|
private static string Label(CatalogLine line)
|
||||||
|
=> string.IsNullOrWhiteSpace(line.NameZh)
|
||||||
|
? $"{line.LineCode} — {line.NameEn}"
|
||||||
|
: $"{line.LineCode} — {line.NameEn} / {line.NameZh}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>Selects the active category-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
|
||||||
|
public interface IExpenseCategoryAiServiceFactory
|
||||||
|
{
|
||||||
|
Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpenseCategoryAiServiceFactory : IExpenseCategoryAiServiceFactory
|
||||||
|
{
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ClaudeExpenseCategoryAiService _claude;
|
||||||
|
private readonly GeminiExpenseCategoryAiService _gemini;
|
||||||
|
|
||||||
|
public ExpenseCategoryAiServiceFactory(
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
ClaudeExpenseCategoryAiService claude,
|
||||||
|
GeminiExpenseCategoryAiService gemini)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_claude = claude;
|
||||||
|
_gemini = gemini;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 Google Gemini
|
||||||
|
/// <c>generateContent</c> API, using Gemini's structured-output mode (<c>responseSchema</c>). The
|
||||||
|
/// catalog, prompt, and id validation come from <see cref="ExpenseCategoryAiServiceBase"/>; this class
|
||||||
|
/// only owns the Gemini HTTP call + parse.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeminiExpenseCategoryAiService : ExpenseCategoryAiServiceBase
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ILogger<GeminiExpenseCategoryAiService> _logger;
|
||||||
|
|
||||||
|
public GeminiExpenseCategoryAiService(
|
||||||
|
HttpClient http,
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<GeminiExpenseCategoryAiService> 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.GeminiApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini API key is not configured; category AI assist is disabled.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
contents = new[]
|
||||||
|
{
|
||||||
|
new { parts = new[] { new { text = prompt } } },
|
||||||
|
},
|
||||||
|
generationConfig = new
|
||||||
|
{
|
||||||
|
responseMimeType = "application/json",
|
||||||
|
responseSchema = 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||||
|
var text = doc.RootElement
|
||||||
|
.GetProperty("candidates")[0]
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetProperty("parts")[0]
|
||||||
|
.GetProperty("text")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini response contained no text part.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
|
||||||
|
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Gemini category AI assist failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
|
||||||
|
private sealed class GeminiAnswer
|
||||||
|
{
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI assistance for defining an expense category (大項/小項): refine the Chinese name, translate it
|
||||||
|
/// to English, and suggest the matching IRS Form 990 Part IX line.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExpenseCategoryAiService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Refine the entered name, translate it to concise accounting English, and choose the best Form 990
|
||||||
|
/// line from the live catalog (biased by the group/sub context in <paramref name="request"/>).
|
||||||
|
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
|
||||||
|
/// </summary>
|
||||||
|
Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
+1
-1
@@ -106,7 +106,7 @@
|
|||||||
|
|
||||||
<!-- Row 2: Description (optional) + Amount -->
|
<!-- Row 2: Description (optional) + Amount -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<label class="flex flex-col gap-1">Description / 說明 <span class="text-gray-400">(Optional)</span>
|
<label class="flex flex-col gap-1"><span>Description / 說明 <span class="text-gray-400">(Optional)</span></span>
|
||||||
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ export interface ExpenseAiSuggestion {
|
|||||||
confidence: number;
|
confidence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request to AI-assist defining an expense category (大項/小項). */
|
||||||
|
export interface ExpenseCategoryAiRequest {
|
||||||
|
name_zh: string;
|
||||||
|
name_en?: string | null;
|
||||||
|
level: 'group' | 'sub';
|
||||||
|
parentGroupName?: string | null;
|
||||||
|
parentForm990LineId?: number | null;
|
||||||
|
}
|
||||||
|
/** AI suggestion for a category: refined Chinese name, English translation, and a Form 990 line. */
|
||||||
|
export interface CategoryAiSuggestion {
|
||||||
|
chineseName: string | null;
|
||||||
|
englishName: string | null;
|
||||||
|
form990LineId: number | null;
|
||||||
|
form990LineLabel: string | null;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExpenseLineInput {
|
export interface ExpenseLineInput {
|
||||||
categoryGroupId: number; subCategoryId: number; amount: number;
|
categoryGroupId: number; subCategoryId: number; amount: number;
|
||||||
functionalClass: FunctionalClass | null; description: string | null;
|
functionalClass: FunctionalClass | null; description: string | null;
|
||||||
|
|||||||
+66
-6
@@ -53,10 +53,40 @@
|
|||||||
Name (EN) *
|
Name (EN) *
|
||||||
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line -->
|
||||||
名稱 (中)
|
<div class="flex flex-col gap-1 md:col-span-2">
|
||||||
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<label class="flex flex-1 flex-col gap-1">名稱 (中)
|
||||||
|
<kendo-textbox [(ngModel)]="groupForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="(!groupForm.name_zh.trim() && !groupForm.name_en.trim()) || groupAiLoading"
|
||||||
|
(click)="requestGroupAiSuggest()"
|
||||||
|
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||||
|
{{ groupAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.englishName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">English:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.englishName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.chineseName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">中文:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.chineseName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.form990LineLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ groupAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyGroupAiSuggestion()">套用 / Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissGroupAiSuggestion()">忽略 / Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
@@ -90,10 +120,40 @@
|
|||||||
Name (EN) *
|
Name (EN) *
|
||||||
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line (biased by parent group) -->
|
||||||
名稱 (中)
|
<div class="flex flex-col gap-1 md:col-span-2">
|
||||||
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<label class="flex flex-1 flex-col gap-1">名稱 (中)
|
||||||
|
<kendo-textbox [(ngModel)]="subForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="(!subForm.name_zh.trim() && !subForm.name_en.trim()) || subAiLoading"
|
||||||
|
(click)="requestSubAiSuggest()"
|
||||||
|
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||||
|
{{ subAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="subAiSuggestion.englishName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">English:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.englishName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion.chineseName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">中文:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.chineseName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.form990LineLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ subAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button" (click)="applySubAiSuggestion()">套用 / Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissSubAiSuggestion()">忽略 / Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
|||||||
+57
-1
@@ -8,7 +8,7 @@ import { InputsModule } from '@progress/kendo-angular-inputs';
|
|||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
||||||
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -34,10 +34,14 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
groupDialogOpen = false;
|
groupDialogOpen = false;
|
||||||
editingGroupId: number | null = null;
|
editingGroupId: number | null = null;
|
||||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||||
|
groupAiLoading = false;
|
||||||
|
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
subDialogOpen = false;
|
subDialogOpen = false;
|
||||||
editingSubId: number | null = null;
|
editingSubId: number | null = null;
|
||||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||||
|
subAiLoading = false;
|
||||||
|
subAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
constructor(private api: ExpenseCategoryApiService) {}
|
constructor(private api: ExpenseCategoryApiService) {}
|
||||||
|
|
||||||
@@ -108,13 +112,36 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
openNewGroup(): void {
|
openNewGroup(): void {
|
||||||
this.editingGroupId = null;
|
this.editingGroupId = null;
|
||||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
||||||
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||||
this.editingGroupId = g.id;
|
this.editingGroupId = g.id;
|
||||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
||||||
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group AI assist: refine the Chinese name, translate to English, suggest a Form 990 line ──
|
||||||
|
private resetGroupAi(): void { this.groupAiLoading = false; this.groupAiSuggestion = null; }
|
||||||
|
requestGroupAiSuggest(): void {
|
||||||
|
if (this.groupAiLoading) return;
|
||||||
|
this.groupAiLoading = true;
|
||||||
|
this.groupAiSuggestion = null;
|
||||||
|
this.api.aiSuggest({ name_zh: this.groupForm.name_zh.trim(), name_en: this.groupForm.name_en.trim() || null, level: 'group' }).subscribe({
|
||||||
|
next: s => { this.groupAiSuggestion = s; this.groupAiLoading = false; },
|
||||||
|
error: () => { this.groupAiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyGroupAiSuggestion(): void {
|
||||||
|
const s = this.groupAiSuggestion;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.chineseName) this.groupForm.name_zh = s.chineseName;
|
||||||
|
if (s.englishName) this.groupForm.name_en = s.englishName;
|
||||||
|
if (s.form990LineId != null) this.groupForm.form990LineId = s.form990LineId;
|
||||||
|
this.groupAiSuggestion = null;
|
||||||
|
}
|
||||||
|
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
||||||
saveGroup(): void {
|
saveGroup(): void {
|
||||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
||||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||||
@@ -130,13 +157,42 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
this.editingSubId = null;
|
this.editingSubId = null;
|
||||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
||||||
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||||
this.editingSubId = s.id;
|
this.editingSubId = s.id;
|
||||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
||||||
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Subcategory AI assist: same as group, biased by the selected parent group + its 990 line ──
|
||||||
|
private resetSubAi(): void { this.subAiLoading = false; this.subAiSuggestion = null; }
|
||||||
|
requestSubAiSuggest(): void {
|
||||||
|
if (this.subAiLoading || !this.selectedGroup) return;
|
||||||
|
this.subAiLoading = true;
|
||||||
|
this.subAiSuggestion = null;
|
||||||
|
this.api.aiSuggest({
|
||||||
|
name_zh: this.subForm.name_zh.trim(),
|
||||||
|
name_en: this.subForm.name_en.trim() || null,
|
||||||
|
level: 'sub',
|
||||||
|
parentGroupName: this.selectedGroup.label ?? this.selectedGroup.name_en,
|
||||||
|
parentForm990LineId: this.selectedGroup.form990LineId,
|
||||||
|
}).subscribe({
|
||||||
|
next: s => { this.subAiSuggestion = s; this.subAiLoading = false; },
|
||||||
|
error: () => { this.subAiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applySubAiSuggestion(): void {
|
||||||
|
const s = this.subAiSuggestion;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.chineseName) this.subForm.name_zh = s.chineseName;
|
||||||
|
if (s.englishName) this.subForm.name_en = s.englishName;
|
||||||
|
if (s.form990LineId != null) this.subForm.form990LineId = s.form990LineId;
|
||||||
|
this.subAiSuggestion = null;
|
||||||
|
}
|
||||||
|
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
||||||
saveSub(): void {
|
saveSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service';
|
|||||||
import {
|
import {
|
||||||
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
|
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
|
||||||
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
|
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
|
||||||
|
ExpenseCategoryAiRequest, CategoryAiSuggestion,
|
||||||
} from '../models/expense.model';
|
} from '../models/expense.model';
|
||||||
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ export class ExpenseCategoryApiService {
|
|||||||
createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); }
|
createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); }
|
||||||
updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
|
updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
|
||||||
deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
|
deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
|
||||||
|
aiSuggest(req: ExpenseCategoryAiRequest): Observable<CategoryAiSuggestion> {
|
||||||
|
return this.http.post<CategoryAiSuggestion>(`${this.endpoint}/ai-suggest`, req);
|
||||||
|
}
|
||||||
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
|
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
|
||||||
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
||||||
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
||||||
|
|||||||
Reference in New Issue
Block a user