Compare commits
12 Commits
fa3e75a333
...
73077295a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 73077295a4 | |||
| c5b1a9372a | |||
| ece2676e38 | |||
| 26259c252d | |||
| 120240ad0c | |||
| ece9938bfb | |||
| a16e21dbfd | |||
| 75905e7036 | |||
| bcaa3e2f25 | |||
| 5448a9ff85 | |||
| bdccb79029 | |||
| a89e936f4d |
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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..];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+47
@@ -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>
|
||||
|
||||
+26
-4
@@ -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;
|
||||
|
||||
+76
-10
@@ -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>
|
||||
|
||||
+104
-1
@@ -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;
|
||||
|
||||
+68
-8
@@ -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>
|
||||
|
||||
+57
-1
@@ -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 18–32) 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 36–40) 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 15–29) 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 33–37) 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 12–26, 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 182–200) 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 47–54) 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 17–20):
|
||||
|
||||
```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 37–49) 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).
|
||||
Reference in New Issue
Block a user