Compare commits

...

12 Commits

Author SHA1 Message Date
Chris Chen 73077295a4 feat(expense-categories): AI 建議 for group/sub name + 990 line
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>
2026-06-25 14:18:34 -07:00
Chris Chen c5b1a9372a test(ai): cover config-provider default fallback when no profile row 2026-06-25 13:34:20 -07:00
Chris Chen ece2676e38 style(church-profile): lead AI tab title with English for consistency
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:32:53 -07:00
Chris Chen 26259c252d feat(church-profile): AI 設定 tab (provider/model/key) with masked keys 2026-06-25 13:29:52 -07:00
Chris Chen 120240ad0c feat(ai): DB-only config + runtime provider selection via factory 2026-06-25 13:23:13 -07:00
Chris Chen ece9938bfb feat(ai): add DB-backed church AI config provider 2026-06-25 13:18:04 -07:00
Chris Chen a16e21dbfd feat(church-profile): masked-read + leave-unchanged write for AI keys 2026-06-25 13:13:42 -07:00
Chris Chen 75905e7036 feat(church-profile): add AI provider/model/key columns + migration 2026-06-25 13:07:30 -07:00
Chris Chen bcaa3e2f25 Add implementation plan: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:03:53 -07:00
Chris Chen 5448a9ff85 Add design spec: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:55:39 -07:00
Chris Chen bdccb79029 WIP 2026-06-25 12:47:14 -07:00
Chris Chen a89e936f4d Implement AI 2026-06-25 11:11:26 -07:00
40 changed files with 5094 additions and 26 deletions
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Services.Ai;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchAiConfigProviderTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
[Fact]
public async Task GetAsync_returns_defaults_when_no_profile_row()
{
using var db = NewDb(); // empty DB, no ChurchProfile
var cfg = await new ChurchAiConfigProvider(db).GetAsync();
Assert.Equal("Claude", cfg.Provider);
Assert.Equal("claude-haiku-4-5-20251001", cfg.ClaudeModel);
Assert.Equal("gemini-2.5-flash-lite", cfg.GeminiModel);
Assert.Null(cfg.ClaudeApiKey);
Assert.Null(cfg.GeminiApiKey);
}
}
@@ -0,0 +1,103 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchProfileServiceTests
{
// 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 UpdateChurchProfileRequest Req(
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
string? claudeModel = "m", string? geminiModel = "m") =>
new()
{
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
ClaudeModel = claudeModel, GeminiModel = geminiModel,
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
};
[Fact]
public async Task GetAsync_masks_stored_api_keys()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
});
await db.SaveChangesAsync();
var dto = await new ChurchProfileService(db).GetAsync();
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
}
[Fact]
public async Task UpdateAsync_blank_key_keeps_existing()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_nonblank_key_replaces()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_sets_provider_and_models()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("Gemini", p.AiProvider);
Assert.Equal("claude-x", p.ClaudeModel);
Assert.Equal("gemini-y", p.GeminiModel);
}
}
@@ -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 ExpenseAiServiceFactoryTests
{
// 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 ExpenseAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
var gemini = new GeminiExpenseAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
return new ExpenseAiServiceFactory(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<ClaudeExpenseAiService>(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<GeminiExpenseAiService>(svc);
}
}
@@ -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);
}
}
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/expense-ai")]
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
public class ExpenseAiController : ControllerBase
{
private readonly IExpenseAiServiceFactory _factory;
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
[HttpPost("assist")]
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Text))
return BadRequest("Text is required.");
var svc = await _factory.ResolveAsync(ct);
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
return Ok(suggestion);
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers;
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
public class ExpenseCategoriesController : ControllerBase
{
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]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> 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")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
@@ -17,6 +17,11 @@ public class ChurchProfileDto
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
public int NextCheckNumber { get; set; }
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
public string? ClaudeApiKeyMasked { get; set; }
public string? GeminiModel { get; set; }
public string? GeminiApiKeyMasked { get; set; }
}
public class UpdateChurchProfileRequest
@@ -34,4 +39,9 @@ public class UpdateChurchProfileRequest
[MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
[MaxLength(100)] public string? GeminiModel { get; set; }
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
}
@@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
/// <summary>Request body for the expense AI assist endpoint.</summary>
public class ExpenseAiAssistRequest
{
/// <summary>The user's free-text expense description (typically Chinese).</summary>
[Required] public string Text { get; set; } = "";
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
public decimal Amount { get; set; }
}
/// <summary>
/// AI suggestion for an expense: an English translation of the description plus a proposed
/// major category (大項) and sub-category (系項). Category ids are null when the model could
/// not confidently classify or returned an id outside the live catalog.
/// </summary>
public class ExpenseAiSuggestion
{
public string? EnglishDescription { get; set; }
/// <summary>Typo-corrected, refined Traditional Chinese description.</summary>
public string? ChineseDescription { get; set; }
public int? GroupId { get; set; }
public int? SubCategoryId { get; set; }
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
public string? GroupLabel { get; set; }
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
public string? SubLabel { get; set; }
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
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; }
}
+5
View File
@@ -309,6 +309,11 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Website).HasMaxLength(300);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
// Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion();
});
+7
View File
@@ -21,6 +21,13 @@ public class ChurchProfile : AuditableEntity, IAuditable
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
public string? ClaudeApiKey { get; set; } // secret, stored plaintext
public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite";
public string? GeminiApiKey { get; set; } // secret, stored plaintext
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
public int NextCheckNumber { get; set; } = 1001;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddChurchAiSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AiProvider",
table: "ChurchProfiles",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Claude");
migrationBuilder.AddColumn<string>(
name: "ClaudeApiKey",
table: "ChurchProfiles",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClaudeModel",
table: "ChurchProfiles",
type: "character varying(100)",
maxLength: 100,
nullable: true,
defaultValue: "claude-haiku-4-5-20251001");
migrationBuilder.AddColumn<string>(
name: "GeminiApiKey",
table: "ChurchProfiles",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GeminiModel",
table: "ChurchProfiles",
type: "character varying(100)",
maxLength: 100,
nullable: true,
defaultValue: "gemini-2.5-flash-lite");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiProvider",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "ClaudeApiKey",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "ClaudeModel",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "GeminiApiKey",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "GeminiModel",
table: "ChurchProfiles");
}
}
}
@@ -439,6 +439,13 @@ namespace ROLAC.API.Migrations
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AiProvider")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Claude");
b.Property<string>("BankAccountNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -455,6 +462,16 @@ namespace ROLAC.API.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClaudeApiKey")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ClaudeModel")
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasDefaultValue("claude-haiku-4-5-20251001");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -467,6 +484,16 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("GeminiApiKey")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("GeminiModel")
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasDefaultValue("gemini-2.5-flash-lite");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
+17
View File
@@ -179,6 +179,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationSer
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>();
// ── AI assist (expense translation + category suggestion) ──────────────────
// Backend proxy so the API key stays server-side. Provider + model + key come from the
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
// or Gemini per request based on ChurchProfile.AiProvider.
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
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)
// ---------------------------------------------------------------------------
@@ -0,0 +1,13 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
public sealed record ChurchAiConfig(
string Provider,
string ClaudeModel, string? ClaudeApiKey,
string GeminiModel, string? GeminiApiKey);
/// <summary>Reads the church's AI settings from the database for the current request.</summary>
public interface IChurchAiConfigProvider
{
Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Loads AI settings from the singleton <c>ChurchProfile</c> row, substituting default model names
/// for any blank field so a freshly migrated install still names a valid model. The API keys are
/// passed through as-is (null when unset → the calling service treats AI as disabled).
/// </summary>
public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider
{
private const string DefaultClaudeModel = "claude-haiku-4-5-20251001";
private const string DefaultGeminiModel = "gemini-2.5-flash-lite";
private readonly AppDbContext _db;
public ChurchAiConfigProvider(AppDbContext db) => _db = db;
public async Task<ChurchAiConfig> GetAsync(CancellationToken ct = default)
{
var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p.AiProvider;
var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!;
var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!;
return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey);
}
}
@@ -0,0 +1,127 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Translates and classifies an expense via the Anthropic Claude Messages API. It forces a single
/// tool call (<c>tool_choice</c> → <c>classify_expense</c>) whose <c>input_schema</c> matches our
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
/// model can be swapped (e.g. to a cheaper model) without code changes.
/// </summary>
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
{
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<ClaudeExpenseAiService> _logger;
public ClaudeExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseAiService> 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; expense AI assist is disabled.");
return null;
}
try
{
var payload = new
{
model = cfg.ClaudeModel,
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 = $"{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.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
}
_logger.LogWarning("Claude response contained no tool_use block.");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Claude expense AI assist failed.");
return null;
}
}
/// <summary>Shape of the classify_expense tool input the model fills in.</summary>
private sealed class ClaudeAnswer
{
public string? EnglishDescription { get; set; }
public string? ChineseDescription { get; set; }
public int GroupId { get; set; }
public int SubCategoryId { get; set; }
public double Confidence { get; set; }
}
}
@@ -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,119 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// 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 <see cref="CallModelAsync"/> — the HTTP call plus
/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
/// </summary>
public abstract class ExpenseAiServiceBase : IExpenseAiService
{
private readonly AppDbContext _db;
protected ExpenseAiServiceBase(AppDbContext db) => _db = db;
/// <summary>One sub-category in the catalog passed to the model.</summary>
protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);
/// <summary>One major category (with its sub-categories) in the catalog passed to the model.</summary>
protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList<CatalogSub> Subs);
/// <summary>The model's raw answer, before its ids are validated against the catalog.</summary>
protected sealed record ModelAnswer(
string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);
public async Task<ExpenseAiSuggestion> 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);
}
/// <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<CatalogGroup>> 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<CatalogGroup> 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<CatalogGroup> 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;
}
/// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
private static string Label(string nameEn, string? nameZh)
=> string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
}
@@ -0,0 +1,30 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseAiServiceFactory
{
Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseAiService _claude;
private readonly GeminiExpenseAiService _gemini;
public ExpenseAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseAiService claude,
GeminiExpenseAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
@@ -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,120 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Translates and classifies an expense 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="ExpenseAiServiceBase"/>; this class only owns the Gemini HTTP call + parse.
/// </summary>
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
{
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseAiService> _logger;
public GeminiExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseAiService> 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; expense 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
{
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" },
},
},
};
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.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
}
catch (Exception ex)
{
_logger.LogError(ex, "Gemini expense AI assist failed.");
return null;
}
}
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
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; }
}
}
@@ -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,14 @@
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>AI assistance for expense entry: translate a description and suggest a category.</summary>
public interface IExpenseAiService
{
/// <summary>
/// Translate <paramref name="chineseText"/> to concise accounting English and suggest the best
/// major/sub category from the live catalog, using <paramref name="amount"/> as a hint.
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
/// </summary>
Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default);
}
@@ -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);
}
@@ -19,6 +19,11 @@ public class ChurchProfileService : IChurchProfileService
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
GeminiModel = p.GeminiModel,
GeminiApiKeyMasked = Mask(p.GeminiApiKey),
};
}
@@ -29,6 +34,12 @@ public class ChurchProfileService : IChurchProfileService
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel;
// Leave-unchanged semantics: only overwrite a stored key when a new value is supplied.
if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey;
if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey;
await _db.SaveChangesAsync();
}
@@ -43,4 +54,12 @@ public class ChurchProfileService : IChurchProfileService
}
return p;
}
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
private static string Mask(string? key)
{
if (string.IsNullOrEmpty(key)) return "";
if (key.Length <= 4) return new string('•', key.Length);
return new string('•', 6) + key[^4..];
}
}
+14
View File
@@ -41,5 +41,19 @@
"Line": {
"ChannelAccessToken": "",
"ChannelSecret": ""
},
"Gemini": {
"ApiKey": "",
"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"
}
}
@@ -49,6 +49,11 @@ export interface ChurchProfileDto {
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null;
}
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>;
export type UpdateChurchProfileRequest =
Omit<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
& { claudeApiKey: string | null; geminiApiKey: string | null };
@@ -82,5 +82,52 @@
<app-notification-settings-tab></app-notification-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
<kendo-tabstrip-tab title="AI Settings / AI 設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
AI Provider / AI 供應商
<kendo-dropdownlist
[data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.aiProvider"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Claude Model / Claude 模型
<kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Claude API Key / Claude 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="claudeApiKeyInput"
[placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<label class="flex flex-col gap-1">
Gemini Model / Gemini 模型
<kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Gemini API Key / Gemini 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<p class="md:col-span-2 text-sm" style="color:#6b7280;">
Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
</p>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
</kendo-tabstrip>
</div>
@@ -4,8 +4,9 @@ import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model';
import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
import { PermissionModules } from '../../../../core/models/permission.model';
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
@@ -15,7 +16,7 @@ import { NotificationSettingsTabComponent } from '../../../settings/components/n
selector: 'app-church-profile-page',
standalone: true,
imports: [
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
],
templateUrl: './church-profile-page.component.html',
@@ -25,6 +26,15 @@ export class ChurchProfilePageComponent implements OnInit {
saving = false;
savedMsg = '';
/** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */
claudeApiKeyInput = '';
geminiApiKeyInput = '';
readonly aiProviders = [
{ text: 'Claude', value: 'Claude' },
{ text: 'Gemini', value: 'Gemini' },
];
/** Settings module gates the Site / Notification tabs. */
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
@@ -38,9 +48,21 @@ export class ChurchProfilePageComponent implements OnInit {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const { id, ...req } = this.model;
const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model;
const req: UpdateChurchProfileRequest = {
...rest,
claudeApiKey: this.claudeApiKeyInput.trim() || null,
geminiApiKey: this.geminiApiKeyInput.trim() || null,
};
this.api.updateChurchProfile(req).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear the key inputs and reload so the masked placeholders reflect the new keys.
this.claudeApiKeyInput = '';
this.geminiApiKeyInput = '';
this.api.getChurchProfile().subscribe(p => (this.model = p));
},
error: () => {
// Error message is shown globally by httpErrorInterceptor.
this.saving = false;
@@ -1,4 +1,5 @@
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'"
[maxHeight]="'90vh'">
<!-- Two columns on desktop: form on the left, receipt preview on the right. Stacks on mobile. -->
<div class="flex flex-col gap-4 md:flex-row">
@@ -10,10 +11,42 @@
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
</label>
<!-- Description (with AI assist: translate to English + suggest a category) -->
<div class="flex flex-col gap-1 md:col-span-2">
<div class="flex items-end justify-between gap-2">
<label class="flex flex-1 flex-col gap-1">Description
<kendo-textbox [(ngModel)]="form.description"
placeholder="Brief description of expense / 費用說明(可輸入中文)"></kendo-textbox>
</label>
<button kendoButton fillMode="outline" themeColor="primary" type="button"
[disabled]="!form.description.trim() || aiLoading" (click)="requestAiAssist()"
title="Translate to English and suggest a category / 翻譯並建議分類">
{{ aiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
</button>
</div>
<!-- Suggestion card: the "suggest & confirm" step — user applies or dismisses -->
<div *ngIf="hasAiSuggestion" 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="aiSuggestedDescription" class="flex gap-2">
<span class="text-gray-500 shrink-0">說明 / Description:</span>
<span class="font-medium">{{ aiSuggestedDescription }}</span>
</div>
<div *ngIf="aiSuggestion?.groupLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">分類 / Category:</span>
<span class="font-medium">{{ aiSuggestion?.groupLabel }}<span *ngIf="aiSuggestion?.subLabel"> → {{
aiSuggestion?.subLabel }}</span></span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0'
}}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyAiSuggestion()">套用 /
Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissAiSuggestion()">忽略 /
Dismiss</button>
</div>
</div>
</div>
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
@@ -45,6 +78,11 @@
<!-- Line header: number + remove -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">明細 {{ i + 1 }} / Item {{ i + 1 }}</span>
<button kendoButton fillMode="outline" themeColor="primary" size="small" type="button"
[disabled]="!line.description.trim() || line.aiLoading" (click)="requestLineAiAssist(line)"
title="Translate this line and suggest a category / 翻譯並建議此列分類">
{{ line.aiLoading ? '思考中… / Thinking…' : '✨ AI 建議此列' }}
</button>
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
</div>
@@ -68,7 +106,7 @@
<!-- Row 2: Description (optional) + Amount -->
<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>
</label>
@@ -76,6 +114,34 @@
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
</div>
<!-- Per-line AI assist: translate this line's note + suggest its category from its own amount -->
<div>
</div>
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
<div *ngIf="hasLineSuggestion(line)"
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="lineSuggestedDescription(line)" class="flex gap-2">
<span class="text-gray-500 shrink-0">說明 / Description:</span>
<span class="font-medium">{{ lineSuggestedDescription(line) }}</span>
</div>
<div *ngIf="line.aiSuggestion?.groupLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">分類 / Category:</span>
<span class="font-medium">{{ line.aiSuggestion?.groupLabel }}<span *ngIf="line.aiSuggestion?.subLabel">
{{ line.aiSuggestion?.subLabel }}</span></span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (line.aiSuggestion?.confidence ?? 0) * 100 |
number:'1.0-0' }}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button"
(click)="applyLineAiSuggestion(line)">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissLineAiSuggestion(line)">忽略
/ Dismiss</button>
</div>
</div>
</div>
<div>
@@ -117,11 +183,11 @@
<span class="font-semibold">收據預覽 / Receipt</span>
<!-- Zoom controls (image only; PDF uses the browser viewer's own zoom) -->
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out"></button>
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()" [disabled]="receiptZoom <= minZoom"
title="縮小 / Zoom out"></button>
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()" [disabled]="receiptZoom >= maxZoom"
title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
@@ -10,11 +10,12 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseAiService } from '../../services/expense-ai.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseDto, FunctionalClass,
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
} from '../../models/expense.model';
export interface ExpenseFormResult {
@@ -37,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({
@@ -101,11 +105,18 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
resetZoom(): void { this.receiptZoom = 1; }
// ── AI assist (translate description + suggest category) ────────────────────
/** True while an assist request is in flight (disables the button, shows a spinner label). */
aiLoading = false;
/** The latest suggestion awaiting the user's Apply/Dismiss decision; null when none is shown. */
aiSuggestion: ExpenseAiSuggestion | null = null;
constructor(
private ministryApi: MinistryApiService,
private catApi: ExpenseCategoryApiService,
private memberApi: MemberApiService,
private expenseApi: ExpenseApiService,
private aiApi: ExpenseAiService,
private sanitizer: DomSanitizer,
) {}
@@ -166,6 +177,98 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
}
/** Ask the backend AI to translate the description and suggest a category; show it for confirmation. */
requestAiAssist(): void {
const text = this.form.description.trim();
if (!text || this.aiLoading) return;
this.aiLoading = true;
this.aiSuggestion = null;
this.aiApi.assist(text, this.totalAmount).subscribe({
next: suggestion => { this.aiSuggestion = suggestion; this.aiLoading = false; },
error: () => { this.aiLoading = false; },
});
}
/** True once a suggestion offers at least a translation or a category to apply. */
get hasAiSuggestion(): boolean {
const s = this.aiSuggestion;
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: 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 (this.aiSuggestedDescription) this.form.description = this.aiSuggestedDescription;
if (suggestion.groupId != null) {
const firstLine = this.lines[0];
firstLine.categoryGroupId = suggestion.groupId;
// Populate the sub-category list for the chosen group, then select the suggested sub.
this.onLineGroupChange(firstLine, suggestion.groupId);
if (suggestion.subCategoryId != null) firstLine.subCategoryId = suggestion.subCategoryId;
}
this.aiSuggestion = null;
}
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 {
@@ -33,6 +33,34 @@ export interface ExpenseDto extends ExpenseListItemDto {
submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
lines: ExpenseLineItemDto[];
}
/** 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;
subLabel: string | null;
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 {
categoryGroupId: number; subCategoryId: number; amount: number;
functionalClass: FunctionalClass | null; description: string | null;
@@ -53,10 +53,40 @@
Name (EN) *
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
</label>
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line -->
<div class="flex flex-col gap-1 md:col-span-2">
<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>
<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">
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
@@ -90,10 +120,40 @@
Name (EN) *
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
</label>
<!-- 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">
<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>
<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">
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
@@ -8,7 +8,7 @@ import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
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';
@Component({
@@ -34,10 +34,14 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
groupAiLoading = false;
groupAiSuggestion: CategoryAiSuggestion | null = null;
subDialogOpen = false;
editingSubId: number | null = 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) {}
@@ -108,13 +112,36 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
this.resetGroupAi();
this.groupDialogOpen = true;
}
openEditGroup(g: ExpenseCategoryGroupDto): void {
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.resetGroupAi();
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 {
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(); };
@@ -130,13 +157,42 @@ export class ExpenseCategoriesPageComponent implements OnInit {
if (!this.selectedGroup) return;
this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
this.resetSubAi();
this.subDialogOpen = true;
}
openEditSub(s: ExpenseSubCategoryDto): void {
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.resetSubAi();
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 {
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 };
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { ExpenseAiSuggestion } from '../models/expense.model';
@Injectable({ providedIn: 'root' })
export class ExpenseAiService {
private readonly endpoint: string;
constructor(private http: HttpClient, private apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('expense-ai');
}
/** Ask the backend (which proxies Gemini) to translate the text and suggest a category. */
assist(text: string, amount: number): Observable<ExpenseAiSuggestion> {
return this.http.post<ExpenseAiSuggestion>(`${this.endpoint}/assist`, { text, amount });
}
}
@@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service';
import {
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
ExpenseCategoryAiRequest, CategoryAiSuggestion,
} from '../models/expense.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); }
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}`); }
aiSuggest(req: ExpenseCategoryAiRequest): Observable<CategoryAiSuggestion> {
return this.http.post<CategoryAiSuggestion>(`${this.endpoint}/ai-suggest`, req);
}
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
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 : ''}` }))));
@@ -0,0 +1,849 @@
# Church Profile AI Settings Tab — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store the AI provider/model/API-key in the `ChurchProfile` DB record and edit them from a new "AI 設定" tab on the Church Profile page; the expense-AI services read their config from the DB per request instead of from `appsettings.json`.
**Architecture:** Add 5 columns to the singleton `ChurchProfile` entity. The GET endpoint returns masked keys; PUT updates a key only when a new value is typed. A new `IChurchAiConfigProvider` reads the active config from the DB, and a new `IExpenseAiServiceFactory` picks Claude vs Gemini per request (replacing the boot-time `Ai:Provider` DI binding). The two AI services drop `IOptions` and read model+key from the config provider; `BaseUrl`/`AnthropicVersion` become code constants.
**Tech Stack:** C# / .NET 8 / EF Core (Npgsql) / xUnit + EF InMemory; Angular 20 standalone + Kendo UI v20 + Tailwind v4.
**Spec:** [docs/superpowers/specs/2026-06-25-church-ai-settings-design.md](../specs/2026-06-25-church-ai-settings-design.md)
**Build/test reminders (from project memory):**
- CLI builds/tests must use `-c Release` (VS holds the Debug DLL lock). `dotnet ef` must pass `--configuration Release`.
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`.
- Frontend build: `cd APP && npx ng build` (no unit test added — the page uses an external `templateUrl`, which the scoped test runner can't load).
---
### Task 1: Add AI columns to ChurchProfile entity + migration
**Files:**
- Modify: `API/ROLAC.API/Entities/ChurchProfile.cs`
- Modify: `API/ROLAC.API/Data/AppDbContext.cs:295-314` (ChurchProfile entity block)
- Create (generated): `API/ROLAC.API/Migrations/<timestamp>_AddChurchAiSettings.cs`
- [ ] **Step 1: Add the 5 properties to the entity**
In `ChurchProfile.cs`, after the `BankRoutingNumber` property (line 22), insert:
```csharp
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
public string? ClaudeApiKey { get; set; } // secret, stored plaintext
public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite";
public string? GeminiApiKey { get; set; } // secret, stored plaintext
```
- [ ] **Step 2: Configure the columns in AppDbContext**
In `AppDbContext.cs`, inside the `builder.Entity<ChurchProfile>(entity => { ... })` block, just before the `entity.Property(e => e.xmin).IsRowVersion();` line (around line 313), insert:
```csharp
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
```
(The `HasDefaultValue` on provider/model columns backfills the existing singleton row during migration. API-key columns stay null — DB-only means an admin must enter the keys in the new tab before AI works again.)
- [ ] **Step 3: Generate the migration**
Run from the repo root (`E:/VSProject/ROLAC`):
```bash
dotnet ef migrations add AddChurchAiSettings --project API/ROLAC.API/ROLAC.API.csproj --configuration Release
```
Expected: a new `Migrations/<timestamp>_AddChurchAiSettings.cs` is created whose `Up()` calls `migrationBuilder.AddColumn<string>` five times (AiProvider, ClaudeModel, ClaudeApiKey, GeminiModel, GeminiApiKey) on table `ChurchProfiles`, with `defaultValue: "Claude"` / `"claude-haiku-4-5-20251001"` / `"gemini-2.5-flash-lite"` on the three non-secret columns. `AppDbContextModelSnapshot.cs` is updated.
- [ ] **Step 4: Verify it builds**
Run:
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: Build succeeded, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add API/ROLAC.API/Entities/ChurchProfile.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations/
git commit -m "feat(church-profile): add AI provider/model/key columns + migration"
```
---
### Task 2: DTOs + ChurchProfileService (masked read, leave-unchanged write)
**Files:**
- Modify: `API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs`
- Modify: `API/ROLAC.API/Services/ChurchProfileService.cs`
- Create: `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs`
- [ ] **Step 1: Add fields to both DTOs**
In `ChurchProfileDtos.cs`, add to `ChurchProfileDto` (after `NextCheckNumber`, line 19):
```csharp
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
public string? ClaudeApiKeyMasked { get; set; }
public string? GeminiModel { get; set; }
public string? GeminiApiKeyMasked { get; set; }
```
And add to `UpdateChurchProfileRequest` (after `NextCheckNumber`, line 36):
```csharp
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
[MaxLength(100)] public string? GeminiModel { get; set; }
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
```
- [ ] **Step 2: Write the failing tests**
Create `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchProfileServiceTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private static UpdateChurchProfileRequest Req(
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
string? claudeModel = "m", string? geminiModel = "m") =>
new()
{
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
ClaudeModel = claudeModel, GeminiModel = geminiModel,
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
};
[Fact]
public async Task GetAsync_masks_stored_api_keys()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
});
await db.SaveChangesAsync();
var dto = await new ChurchProfileService(db).GetAsync();
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
}
[Fact]
public async Task UpdateAsync_blank_key_keeps_existing()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_nonblank_key_replaces()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_sets_provider_and_models()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("Gemini", p.AiProvider);
Assert.Equal("claude-x", p.ClaudeModel);
Assert.Equal("gemini-y", p.GeminiModel);
}
}
```
- [ ] **Step 3: Run the tests to confirm they fail**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests"
```
Expected: FAIL — assertions on `ClaudeApiKeyMasked` (null vs masked) and the unchanged service not yet honoring keys/provider.
- [ ] **Step 4: Implement masking + key-preserving update in the service**
In `ChurchProfileService.cs`, extend the `GetAsync` object initializer (after `NextCheckNumber = p.NextCheckNumber,`):
```csharp
AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
GeminiModel = p.GeminiModel,
GeminiApiKeyMasked = Mask(p.GeminiApiKey),
```
In `UpdateAsync`, after the `p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;` line, insert:
```csharp
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel;
// Leave-unchanged semantics: only overwrite a stored key when a new value is supplied.
if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey;
if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey;
```
Add this private helper at the end of the class (before the final closing brace):
```csharp
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
private static string Mask(string? key)
{
if (string.IsNullOrEmpty(key)) return "";
if (key.Length <= 4) return new string('•', key.Length);
return new string('•', 6) + key[^4..];
}
```
- [ ] **Step 5: Run the tests to confirm they pass**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests"
```
Expected: PASS (4 tests).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs API/ROLAC.API/Services/ChurchProfileService.cs API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs
git commit -m "feat(church-profile): masked-read + leave-unchanged write for AI keys"
```
---
### Task 3: Church AI config provider (DB-backed)
**Files:**
- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs`
- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs`
- [ ] **Step 1: Create the config record + interface**
Create `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs`:
```csharp
namespace ROLAC.API.Services.Ai;
/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
public sealed record ChurchAiConfig(
string Provider,
string ClaudeModel, string? ClaudeApiKey,
string GeminiModel, string? GeminiApiKey);
/// <summary>Reads the church's AI settings from the database for the current request.</summary>
public interface IChurchAiConfigProvider
{
Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
}
```
- [ ] **Step 2: Create the implementation**
Create `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Loads AI settings from the singleton <c>ChurchProfile</c> row, substituting default model names
/// for any blank field so a freshly migrated install still names a valid model. The API keys are
/// passed through as-is (null when unset → the calling service treats AI as disabled).
/// </summary>
public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider
{
private const string DefaultClaudeModel = "claude-haiku-4-5-20251001";
private const string DefaultGeminiModel = "gemini-2.5-flash-lite";
private readonly AppDbContext _db;
public ChurchAiConfigProvider(AppDbContext db) => _db = db;
public async Task<ChurchAiConfig> GetAsync(CancellationToken ct = default)
{
var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p!.AiProvider;
var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!;
var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!;
return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey);
}
}
```
- [ ] **Step 3: Verify it builds**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ChurchAiConfig.cs API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs
git commit -m "feat(ai): add DB-backed church AI config provider"
```
---
### Task 4: AI services read from DB config (drop IOptions)
**Files:**
- Modify: `API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs`
- Modify: `API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs`
- Delete: `API/ROLAC.API/Services/Ai/ClaudeOptions.cs`
- Delete: `API/ROLAC.API/Services/Ai/GeminiOptions.cs`
- [ ] **Step 1: Rewire ClaudeExpenseAiService**
In `ClaudeExpenseAiService.cs`:
Replace the `using Microsoft.Extensions.Options;` line (line 3) with nothing (remove it).
Replace the fields + constructor (lines 1832) with:
```csharp
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<ClaudeExpenseAiService> _logger;
public ClaudeExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
```
At the top of `CallModelAsync`, replace the key check (lines 3640) with:
```csharp
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
return null;
}
```
In the payload, change `model = _options.Model,` to `model = cfg.ClaudeModel,`.
Change the URL line `var url = $"{_options.BaseUrl}/messages";` to `var url = $"{BaseUrl}/messages";`.
Change the two header lines to:
```csharp
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
```
- [ ] **Step 2: Rewire GeminiExpenseAiService**
In `GeminiExpenseAiService.cs`:
Remove the `using Microsoft.Extensions.Options;` line (line 3).
Replace the fields + constructor (lines 1529) with:
```csharp
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseAiService> _logger;
public GeminiExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
```
At the top of `CallModelAsync`, replace the key check (lines 3337) with:
```csharp
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
{
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
return null;
}
```
Change the URL line to:
```csharp
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
```
Change the header line to:
```csharp
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
```
- [ ] **Step 3: Delete the now-unused Options classes**
```bash
git rm API/ROLAC.API/Services/Ai/ClaudeOptions.cs API/ROLAC.API/Services/Ai/GeminiOptions.cs
```
- [ ] **Step 4: Verify it builds**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: build FAILS only in `Program.cs` (still references `ClaudeOptions`/`GeminiOptions` and the old DI binding) — that is fixed in Task 5. The two service files themselves must have no errors. If any error points at `ClaudeExpenseAiService.cs` or `GeminiExpenseAiService.cs`, fix before proceeding.
- [ ] **Step 5: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs
git commit -m "refactor(ai): read provider config from DB, drop IOptions/Options classes"
```
(Commit even though Program.cs is temporarily broken — Task 5 is the immediate next step and restores the build. If you prefer a green tree at every commit, combine Task 4 + Task 5 into one commit.)
---
### Task 5: Provider factory + controller + DI wiring
**Files:**
- Create: `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs`
- Modify: `API/ROLAC.API/Controllers/ExpenseAiController.cs`
- Modify: `API/ROLAC.API/Program.cs:182-200`
- Create: `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs`
- [ ] **Step 1: Write the failing factory tests**
Create `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseAiServiceFactoryTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private static ExpenseAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
var gemini = new GeminiExpenseAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
return new ExpenseAiServiceFactory(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<ClaudeExpenseAiService>(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<GeminiExpenseAiService>(svc);
}
}
```
- [ ] **Step 2: Run to confirm it fails**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests"
```
Expected: FAIL to compile — `ExpenseAiServiceFactory` / `IExpenseAiServiceFactory` do not exist yet.
- [ ] **Step 3: Create the factory**
Create `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs`:
```csharp
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseAiServiceFactory
{
Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseAiService _claude;
private readonly GeminiExpenseAiService _gemini;
public ExpenseAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseAiService claude,
GeminiExpenseAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
```
- [ ] **Step 4: Update the controller to resolve per request**
Replace the body of `ExpenseAiController.cs` (lines 1226, the class body) so it injects the factory instead of `IExpenseAiService`:
```csharp
public class ExpenseAiController : ControllerBase
{
private readonly IExpenseAiServiceFactory _factory;
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
[HttpPost("assist")]
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Text))
return BadRequest("Text is required.");
var svc = await _factory.ResolveAsync(ct);
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
return Ok(suggestion);
}
}
```
- [ ] **Step 5: Update DI registration in Program.cs**
In `Program.cs`, replace the AI block (lines 182200) with:
```csharp
// ── AI assist (expense translation + category suggestion) ──────────────────
// Backend proxy so the API key stays server-side. Provider + model + key come from the
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
// or Gemini per request based on ChurchProfile.AiProvider.
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
```
(This removes the two `Configure<...Options>` lines and the entire `var aiProvider = ...` if/else binding.)
- [ ] **Step 6: Run the factory tests + full build**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests"
```
Expected: Build succeeded; 2 factory tests PASS.
- [ ] **Step 7: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs API/ROLAC.API/Controllers/ExpenseAiController.cs API/ROLAC.API/Program.cs API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs
git commit -m "feat(ai): runtime provider selection via factory + DI wiring"
```
---
### Task 6: Frontend — model types + "AI 設定" tab
**Files:**
- Modify: `APP/src/app/features/disbursement/models/disbursement.model.ts:47-54`
- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts`
- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html`
- [ ] **Step 1: Extend the model types**
In `disbursement.model.ts`, replace the `ChurchProfileDto` interface and `UpdateChurchProfileRequest` alias (lines 4754) with:
```typescript
export interface ChurchProfileDto {
id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null;
}
export type UpdateChurchProfileRequest =
Omit<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
& { claudeApiKey: string | null; geminiApiKey: string | null };
```
- [ ] **Step 2: Update the component class**
In `church-profile-page.component.ts`:
Add the DropDowns module import after the existing Kendo imports (after line 6):
```typescript
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
```
Add `UpdateChurchProfileRequest` to the model import (line 8):
```typescript
import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';
```
Add `DropDownsModule` to the `imports` array of the `@Component` decorator (append to the list on lines 1720):
```typescript
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
```
Add these members after `savedMsg = '';` (line 26):
```typescript
/** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */
claudeApiKeyInput = '';
geminiApiKeyInput = '';
readonly aiProviders = [
{ text: 'Claude', value: 'Claude' },
{ text: 'Gemini', value: 'Gemini' },
];
```
Replace the `save()` method (lines 3749) with:
```typescript
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model;
const req: UpdateChurchProfileRequest = {
...rest,
claudeApiKey: this.claudeApiKeyInput.trim() || null,
geminiApiKey: this.geminiApiKeyInput.trim() || null,
};
this.api.updateChurchProfile(req).subscribe({
next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear the key inputs and reload so the masked placeholders reflect the new keys.
this.claudeApiKeyInput = '';
this.geminiApiKeyInput = '';
this.api.getChurchProfile().subscribe(p => (this.model = p));
},
error: () => {
// Error message is shown globally by httpErrorInterceptor.
this.saving = false;
},
});
}
```
- [ ] **Step 3: Add the tab to the template**
In `church-profile-page.component.html`, insert this block immediately before the closing `</kendo-tabstrip>` (after the Notifications tab, line 84):
```html
<!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
<kendo-tabstrip-tab title="AI 設定 / AI Settings" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
AI Provider / AI 供應商
<kendo-dropdownlist
[data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.aiProvider"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Claude Model / Claude 模型
<kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Claude API Key / Claude 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="claudeApiKeyInput"
[placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<label class="flex flex-col gap-1">
Gemini Model / Gemini 模型
<kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Gemini API Key / Gemini 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<p class="md:col-span-2 text-sm" style="color:#6b7280;">
Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
</p>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
```
(The password fields use the `kendoTextBox` directive on a native `<input type="password">` — Kendo's `<kendo-textbox>` element has no password mode. This is the intended exception to the "use Kendo element, not directive" convention.)
- [ ] **Step 4: Verify the frontend builds**
```bash
cd APP && npx ng build
```
Expected: build succeeds with no template/type errors (notably no "property does not exist on ChurchProfileDto" and no missing-module error for `kendo-dropdownlist`).
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/disbursement/models/disbursement.model.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html
git commit -m "feat(church-profile): AI 設定 tab (provider/model/key) with masked keys"
```
---
### Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full backend test suite**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
```
Expected: all tests pass (including the 4 ChurchProfileService + 2 factory tests added here, and no regressions in existing AI/expense tests).
- [ ] **Step 2: Confirm the full API + APP build**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
cd APP && npx ng build && cd ..
```
Expected: both succeed, 0 errors.
- [ ] **Step 3 (optional, manual): smoke-test the tab**
Run the API + `ng serve` (per the build-run memory: temporary `apiUrl` repoint + `--port 4200`), log in as `super_admin`, open Church Profile → **AI 設定**, set provider + a model + paste a key, Save. Reload: the key field shows the masked placeholder (`••••••…`) and the model/provider persist. Then file an expense and use AI assist to confirm the DB-stored key drives the call.
- [ ] **Step 4: Final commit (if any verification fixups were needed)**
```bash
git add -A
git commit -m "test(church-profile): verify AI settings end-to-end"
```
---
## Notes / accepted behavior
- **DB-only migration consequence:** after the migration runs, `ClaudeApiKey`/`GeminiApiKey` are empty, so AI assist is disabled until an admin enters a key in the new tab. This is the explicitly chosen behavior (spec decision 3, option B).
- **appsettings AI config is now dead** (`Ai` / `Claude` / `Gemini` sections). Left inert per spec "out of scope"; can be removed in a separate cleanup.
- **No "clear key" in v1** — the UI can replace a key but not blank an existing one.
- **`BaseUrl` / `AnthropicVersion`** live as constants in the service classes; changing an endpoint/proxy needs a code edit (accepted).
@@ -0,0 +1,112 @@
# Church Profile — AI Settings Tab
**Date:** 2026-06-25
**Status:** Approved (ready for implementation plan)
## Goal
Move AI provider configuration (provider, model, API token) out of `appsettings.json`
and into the `ChurchProfile` DB record, editable through a new **AI 設定** tab on the
Church Profile page. The AI expense-assist services read their configuration from the
database at request time instead of from startup `IOptions`.
## Decisions (from brainstorming)
1. **Per-provider config** — store Claude *and* Gemini settings side by side; a provider
selector chooses which is active. Switching providers does not lose the other's key.
2. **Secret handling: plaintext storage + masked read** — keys stored as-is in the DB
(same exposure model as today's `appsettings.Development.json`); the GET endpoint never
returns the raw key to the browser.
3. **DB-only consumption** — AI services read exclusively from the DB. The `Ai:Provider`
and `Claude`/`Gemini` key/model values in `appsettings.json` become dead config (left
inert; `BaseUrl`/`AnthropicVersion` are the exception — see below).
## 1. Data model — new columns on `ChurchProfile`
The singleton `ChurchProfile` entity (Id == 1) gains:
| Column | Type | Notes |
|----------------|-----------|----------------------------------------|
| `AiProvider` | `string` | `"Claude"` \| `"Gemini"` — active provider |
| `ClaudeModel` | `string?` | |
| `ClaudeApiKey` | `string?` | Secret, stored plaintext |
| `GeminiModel` | `string?` | |
| `GeminiApiKey` | `string?` | Secret, stored plaintext |
`BaseUrl` (Claude + Gemini) and `AnthropicVersion` (Claude) remain **constants in the
service code** — they effectively never change and stay out of the UI and DB to keep both
focused on the three editable values (provider, model, key).
- An EF migration adds the columns.
- The singleton seed sets default values so AI keeps working before anyone opens the tab:
`AiProvider = "Claude"`, `ClaudeModel = "claude-haiku-4-5-20251001"`,
`GeminiModel = "gemini-2.5-flash-lite"`, keys empty.
- Columns participate in the existing `xmin` optimistic-concurrency token already on the entity.
## 2. Secret handling — masked read
**GET `/api/church-profile`** (`ChurchProfileDto`):
- Adds `aiProvider`, `claudeModel`, `geminiModel`.
- Adds `claudeApiKeyMasked` / `geminiApiKeyMasked` — a masked display string
(e.g. `••••••1234`, or empty string when unset). The raw key is **never** serialized.
**PUT `/api/church-profile`** (`UpdateChurchProfileRequest`):
- Adds `aiProvider`, `claudeModel`, `geminiModel`.
- Adds nullable `claudeApiKey` / `geminiApiKey` with **leave-unchanged semantics**:
- `null` or empty string → keep the existing stored key.
- non-empty → overwrite the stored key.
- Clearing an existing key is **out of scope for v1** (easy follow-up if needed).
Masking helper: show the last 4 chars prefixed with bullets; for keys shorter than 4
chars, mask entirely.
## 3. AI services read from DB (DB-only)
- `ClaudeExpenseAiService` / `GeminiExpenseAiService` stop depending on
`IOptions<ClaudeOptions>` / `IOptions<GeminiOptions>`. Instead they obtain the active
config from the `ChurchProfile` record **per request** (via the existing
`IChurchProfileService` / `AppDbContext`, behind a small accessor so the AI base class
stays provider-agnostic).
- **Runtime provider selection** replaces the boot-time DI binding in `Program.cs`:
the `ExpenseAiController` (or a small factory/resolver) inspects
`ChurchProfile.AiProvider` on each request and dispatches to the matching service.
Both services remain registered (`AddHttpClient<...>()`), but the `IExpenseAiService`
fixed-binding block based on `Ai:Provider` is removed.
- `BaseUrl` / `AnthropicVersion` constants live in the respective service classes.
- Empty key → service returns a null suggestion (unchanged "AI disabled" behavior).
## 4. Frontend — "AI 設定" tab
Location: `APP/src/app/features/disbursement/pages/church-profile-page/`.
- Add a 4th `kendo-tabstrip-tab` titled **AI 設定**, guarded by the same
`PermissionModules.Settings` check used by the Site Settings / Notifications tabs.
- Form (Tailwind grid `grid grid-cols-1 md:grid-cols-2`, layout via utilities not SCSS):
- **Provider** — Kendo `DropdownList` (Claude / Gemini), `textField`/`valueField`
against an options array, with `[valuePrimitive]="true"`.
- **Claude** group: Model (text input) + API Key (password input).
- **Gemini** group: Model (text input) + API Key (password input).
- Key inputs display the masked value as placeholder; typing a new value replaces it,
leaving it blank keeps the stored key.
- `ChurchProfileDto` / `UpdateChurchProfileRequest` (TS, in `disbursement.model.ts`) and
`DisbursementApiService` extend with the new fields. **No new endpoint** — reuses the
existing GET/PUT.
- Mobile-friendly per standing rule; the form grid already collapses to one column.
## 5. Testing
- Backend service tests:
- PUT with empty `claudeApiKey` preserves the existing stored key.
- PUT with a non-empty `claudeApiKey` overwrites it.
- GET returns masked keys, never the raw value.
- Provider switch (`AiProvider`) routes an assist request to the correct service.
- Frontend: extend existing church-profile component coverage where present (inline
templates per the unit-test runner constraint).
## Out of scope (v1)
- Encryption-at-rest for keys (chose plaintext + masked read).
- Editable `BaseUrl` / `AnthropicVersion` (kept as code constants).
- Clearing a stored key from the UI.
- Removing the now-dead `Ai` / `Claude` / `Gemini` sections from `appsettings.json`
(left inert; can be cleaned up separately).