Files
ROLAC/docs/superpowers/plans/2026-06-25-church-ai-settings.md
T
2026-06-25 13:03:53 -07:00

850 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Church Profile AI Settings Tab — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store the AI provider/model/API-key in the `ChurchProfile` DB record and edit them from a new "AI 設定" tab on the Church Profile page; the expense-AI services read their config from the DB per request instead of from `appsettings.json`.
**Architecture:** Add 5 columns to the singleton `ChurchProfile` entity. The GET endpoint returns masked keys; PUT updates a key only when a new value is typed. A new `IChurchAiConfigProvider` reads the active config from the DB, and a new `IExpenseAiServiceFactory` picks Claude vs Gemini per request (replacing the boot-time `Ai:Provider` DI binding). The two AI services drop `IOptions` and read model+key from the config provider; `BaseUrl`/`AnthropicVersion` become code constants.
**Tech Stack:** C# / .NET 8 / EF Core (Npgsql) / xUnit + EF InMemory; Angular 20 standalone + Kendo UI v20 + Tailwind v4.
**Spec:** [docs/superpowers/specs/2026-06-25-church-ai-settings-design.md](../specs/2026-06-25-church-ai-settings-design.md)
**Build/test reminders (from project memory):**
- CLI builds/tests must use `-c Release` (VS holds the Debug DLL lock). `dotnet ef` must pass `--configuration Release`.
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`.
- Frontend build: `cd APP && npx ng build` (no unit test added — the page uses an external `templateUrl`, which the scoped test runner can't load).
---
### Task 1: Add AI columns to ChurchProfile entity + migration
**Files:**
- Modify: `API/ROLAC.API/Entities/ChurchProfile.cs`
- Modify: `API/ROLAC.API/Data/AppDbContext.cs:295-314` (ChurchProfile entity block)
- Create (generated): `API/ROLAC.API/Migrations/<timestamp>_AddChurchAiSettings.cs`
- [ ] **Step 1: Add the 5 properties to the entity**
In `ChurchProfile.cs`, after the `BankRoutingNumber` property (line 22), insert:
```csharp
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
public string? ClaudeApiKey { get; set; } // secret, stored plaintext
public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite";
public string? GeminiApiKey { get; set; } // secret, stored plaintext
```
- [ ] **Step 2: Configure the columns in AppDbContext**
In `AppDbContext.cs`, inside the `builder.Entity<ChurchProfile>(entity => { ... })` block, just before the `entity.Property(e => e.xmin).IsRowVersion();` line (around line 313), insert:
```csharp
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
```
(The `HasDefaultValue` on provider/model columns backfills the existing singleton row during migration. API-key columns stay null — DB-only means an admin must enter the keys in the new tab before AI works again.)
- [ ] **Step 3: Generate the migration**
Run from the repo root (`E:/VSProject/ROLAC`):
```bash
dotnet ef migrations add AddChurchAiSettings --project API/ROLAC.API/ROLAC.API.csproj --configuration Release
```
Expected: a new `Migrations/<timestamp>_AddChurchAiSettings.cs` is created whose `Up()` calls `migrationBuilder.AddColumn<string>` five times (AiProvider, ClaudeModel, ClaudeApiKey, GeminiModel, GeminiApiKey) on table `ChurchProfiles`, with `defaultValue: "Claude"` / `"claude-haiku-4-5-20251001"` / `"gemini-2.5-flash-lite"` on the three non-secret columns. `AppDbContextModelSnapshot.cs` is updated.
- [ ] **Step 4: Verify it builds**
Run:
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: Build succeeded, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add API/ROLAC.API/Entities/ChurchProfile.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations/
git commit -m "feat(church-profile): add AI provider/model/key columns + migration"
```
---
### Task 2: DTOs + ChurchProfileService (masked read, leave-unchanged write)
**Files:**
- Modify: `API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs`
- Modify: `API/ROLAC.API/Services/ChurchProfileService.cs`
- Create: `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs`
- [ ] **Step 1: Add fields to both DTOs**
In `ChurchProfileDtos.cs`, add to `ChurchProfileDto` (after `NextCheckNumber`, line 19):
```csharp
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
public string? ClaudeApiKeyMasked { get; set; }
public string? GeminiModel { get; set; }
public string? GeminiApiKeyMasked { get; set; }
```
And add to `UpdateChurchProfileRequest` (after `NextCheckNumber`, line 36):
```csharp
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
[MaxLength(100)] public string? GeminiModel { get; set; }
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
```
- [ ] **Step 2: Write the failing tests**
Create `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchProfileServiceTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private static UpdateChurchProfileRequest Req(
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
string? claudeModel = "m", string? geminiModel = "m") =>
new()
{
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
ClaudeModel = claudeModel, GeminiModel = geminiModel,
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
};
[Fact]
public async Task GetAsync_masks_stored_api_keys()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
});
await db.SaveChangesAsync();
var dto = await new ChurchProfileService(db).GetAsync();
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
}
[Fact]
public async Task UpdateAsync_blank_key_keeps_existing()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_nonblank_key_replaces()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_sets_provider_and_models()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("Gemini", p.AiProvider);
Assert.Equal("claude-x", p.ClaudeModel);
Assert.Equal("gemini-y", p.GeminiModel);
}
}
```
- [ ] **Step 3: Run the tests to confirm they fail**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests"
```
Expected: FAIL — assertions on `ClaudeApiKeyMasked` (null vs masked) and the unchanged service not yet honoring keys/provider.
- [ ] **Step 4: Implement masking + key-preserving update in the service**
In `ChurchProfileService.cs`, extend the `GetAsync` object initializer (after `NextCheckNumber = p.NextCheckNumber,`):
```csharp
AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
GeminiModel = p.GeminiModel,
GeminiApiKeyMasked = Mask(p.GeminiApiKey),
```
In `UpdateAsync`, after the `p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;` line, insert:
```csharp
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel;
// Leave-unchanged semantics: only overwrite a stored key when a new value is supplied.
if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey;
if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey;
```
Add this private helper at the end of the class (before the final closing brace):
```csharp
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
private static string Mask(string? key)
{
if (string.IsNullOrEmpty(key)) return "";
if (key.Length <= 4) return new string('•', key.Length);
return new string('•', 6) + key[^4..];
}
```
- [ ] **Step 5: Run the tests to confirm they pass**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests"
```
Expected: PASS (4 tests).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs API/ROLAC.API/Services/ChurchProfileService.cs API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs
git commit -m "feat(church-profile): masked-read + leave-unchanged write for AI keys"
```
---
### Task 3: Church AI config provider (DB-backed)
**Files:**
- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs`
- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs`
- [ ] **Step 1: Create the config record + interface**
Create `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs`:
```csharp
namespace ROLAC.API.Services.Ai;
/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
public sealed record ChurchAiConfig(
string Provider,
string ClaudeModel, string? ClaudeApiKey,
string GeminiModel, string? GeminiApiKey);
/// <summary>Reads the church's AI settings from the database for the current request.</summary>
public interface IChurchAiConfigProvider
{
Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
}
```
- [ ] **Step 2: Create the implementation**
Create `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Loads AI settings from the singleton <c>ChurchProfile</c> row, substituting default model names
/// for any blank field so a freshly migrated install still names a valid model. The API keys are
/// passed through as-is (null when unset → the calling service treats AI as disabled).
/// </summary>
public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider
{
private const string DefaultClaudeModel = "claude-haiku-4-5-20251001";
private const string DefaultGeminiModel = "gemini-2.5-flash-lite";
private readonly AppDbContext _db;
public ChurchAiConfigProvider(AppDbContext db) => _db = db;
public async Task<ChurchAiConfig> GetAsync(CancellationToken ct = default)
{
var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p!.AiProvider;
var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!;
var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!;
return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey);
}
}
```
- [ ] **Step 3: Verify it builds**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ChurchAiConfig.cs API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs
git commit -m "feat(ai): add DB-backed church AI config provider"
```
---
### Task 4: AI services read from DB config (drop IOptions)
**Files:**
- Modify: `API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs`
- Modify: `API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs`
- Delete: `API/ROLAC.API/Services/Ai/ClaudeOptions.cs`
- Delete: `API/ROLAC.API/Services/Ai/GeminiOptions.cs`
- [ ] **Step 1: Rewire ClaudeExpenseAiService**
In `ClaudeExpenseAiService.cs`:
Replace the `using Microsoft.Extensions.Options;` line (line 3) with nothing (remove it).
Replace the fields + constructor (lines 1832) with:
```csharp
private const string BaseUrl = "https://api.anthropic.com/v1";
private const string AnthropicVersion = "2023-06-01";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<ClaudeExpenseAiService> _logger;
public ClaudeExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
```
At the top of `CallModelAsync`, replace the key check (lines 3640) with:
```csharp
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
return null;
}
```
In the payload, change `model = _options.Model,` to `model = cfg.ClaudeModel,`.
Change the URL line `var url = $"{_options.BaseUrl}/messages";` to `var url = $"{BaseUrl}/messages";`.
Change the two header lines to:
```csharp
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
```
- [ ] **Step 2: Rewire GeminiExpenseAiService**
In `GeminiExpenseAiService.cs`:
Remove the `using Microsoft.Extensions.Options;` line (line 3).
Replace the fields + constructor (lines 1529) with:
```csharp
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseAiService> _logger;
public GeminiExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
```
At the top of `CallModelAsync`, replace the key check (lines 3337) with:
```csharp
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
{
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
return null;
}
```
Change the URL line to:
```csharp
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
```
Change the header line to:
```csharp
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
```
- [ ] **Step 3: Delete the now-unused Options classes**
```bash
git rm API/ROLAC.API/Services/Ai/ClaudeOptions.cs API/ROLAC.API/Services/Ai/GeminiOptions.cs
```
- [ ] **Step 4: Verify it builds**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
```
Expected: build FAILS only in `Program.cs` (still references `ClaudeOptions`/`GeminiOptions` and the old DI binding) — that is fixed in Task 5. The two service files themselves must have no errors. If any error points at `ClaudeExpenseAiService.cs` or `GeminiExpenseAiService.cs`, fix before proceeding.
- [ ] **Step 5: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs
git commit -m "refactor(ai): read provider config from DB, drop IOptions/Options classes"
```
(Commit even though Program.cs is temporarily broken — Task 5 is the immediate next step and restores the build. If you prefer a green tree at every commit, combine Task 4 + Task 5 into one commit.)
---
### Task 5: Provider factory + controller + DI wiring
**Files:**
- Create: `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs`
- Modify: `API/ROLAC.API/Controllers/ExpenseAiController.cs`
- Modify: `API/ROLAC.API/Program.cs:182-200`
- Create: `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs`
- [ ] **Step 1: Write the failing factory tests**
Create `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseAiServiceFactoryTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private static ExpenseAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
var gemini = new GeminiExpenseAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
return new ExpenseAiServiceFactory(cfg, claude, gemini);
}
[Fact]
public async Task Resolves_Claude_by_default()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<ClaudeExpenseAiService>(svc);
}
[Fact]
public async Task Resolves_Gemini_when_selected()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<GeminiExpenseAiService>(svc);
}
}
```
- [ ] **Step 2: Run to confirm it fails**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests"
```
Expected: FAIL to compile — `ExpenseAiServiceFactory` / `IExpenseAiServiceFactory` do not exist yet.
- [ ] **Step 3: Create the factory**
Create `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs`:
```csharp
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseAiServiceFactory
{
Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseAiService _claude;
private readonly GeminiExpenseAiService _gemini;
public ExpenseAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseAiService claude,
GeminiExpenseAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
```
- [ ] **Step 4: Update the controller to resolve per request**
Replace the body of `ExpenseAiController.cs` (lines 1226, the class body) so it injects the factory instead of `IExpenseAiService`:
```csharp
public class ExpenseAiController : ControllerBase
{
private readonly IExpenseAiServiceFactory _factory;
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
[HttpPost("assist")]
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Text))
return BadRequest("Text is required.");
var svc = await _factory.ResolveAsync(ct);
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
return Ok(suggestion);
}
}
```
- [ ] **Step 5: Update DI registration in Program.cs**
In `Program.cs`, replace the AI block (lines 182200) with:
```csharp
// ── AI assist (expense translation + category suggestion) ──────────────────
// Backend proxy so the API key stays server-side. Provider + model + key come from the
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
// or Gemini per request based on ChurchProfile.AiProvider.
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
```
(This removes the two `Configure<...Options>` lines and the entire `var aiProvider = ...` if/else binding.)
- [ ] **Step 6: Run the factory tests + full build**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests"
```
Expected: Build succeeded; 2 factory tests PASS.
- [ ] **Step 7: Commit**
```bash
git add API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs API/ROLAC.API/Controllers/ExpenseAiController.cs API/ROLAC.API/Program.cs API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs
git commit -m "feat(ai): runtime provider selection via factory + DI wiring"
```
---
### Task 6: Frontend — model types + "AI 設定" tab
**Files:**
- Modify: `APP/src/app/features/disbursement/models/disbursement.model.ts:47-54`
- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts`
- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html`
- [ ] **Step 1: Extend the model types**
In `disbursement.model.ts`, replace the `ChurchProfileDto` interface and `UpdateChurchProfileRequest` alias (lines 4754) with:
```typescript
export interface ChurchProfileDto {
id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null;
}
export type UpdateChurchProfileRequest =
Omit<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
& { claudeApiKey: string | null; geminiApiKey: string | null };
```
- [ ] **Step 2: Update the component class**
In `church-profile-page.component.ts`:
Add the DropDowns module import after the existing Kendo imports (after line 6):
```typescript
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
```
Add `UpdateChurchProfileRequest` to the model import (line 8):
```typescript
import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';
```
Add `DropDownsModule` to the `imports` array of the `@Component` decorator (append to the list on lines 1720):
```typescript
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
```
Add these members after `savedMsg = '';` (line 26):
```typescript
/** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */
claudeApiKeyInput = '';
geminiApiKeyInput = '';
readonly aiProviders = [
{ text: 'Claude', value: 'Claude' },
{ text: 'Gemini', value: 'Gemini' },
];
```
Replace the `save()` method (lines 3749) with:
```typescript
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model;
const req: UpdateChurchProfileRequest = {
...rest,
claudeApiKey: this.claudeApiKeyInput.trim() || null,
geminiApiKey: this.geminiApiKeyInput.trim() || null,
};
this.api.updateChurchProfile(req).subscribe({
next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear the key inputs and reload so the masked placeholders reflect the new keys.
this.claudeApiKeyInput = '';
this.geminiApiKeyInput = '';
this.api.getChurchProfile().subscribe(p => (this.model = p));
},
error: () => {
// Error message is shown globally by httpErrorInterceptor.
this.saving = false;
},
});
}
```
- [ ] **Step 3: Add the tab to the template**
In `church-profile-page.component.html`, insert this block immediately before the closing `</kendo-tabstrip>` (after the Notifications tab, line 84):
```html
<!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
<kendo-tabstrip-tab title="AI 設定 / AI Settings" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
AI Provider / AI 供應商
<kendo-dropdownlist
[data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.aiProvider"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Claude Model / Claude 模型
<kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Claude API Key / Claude 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="claudeApiKeyInput"
[placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<label class="flex flex-col gap-1">
Gemini Model / Gemini 模型
<kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Gemini API Key / Gemini 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<p class="md:col-span-2 text-sm" style="color:#6b7280;">
Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
</p>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
```
(The password fields use the `kendoTextBox` directive on a native `<input type="password">` — Kendo's `<kendo-textbox>` element has no password mode. This is the intended exception to the "use Kendo element, not directive" convention.)
- [ ] **Step 4: Verify the frontend builds**
```bash
cd APP && npx ng build
```
Expected: build succeeds with no template/type errors (notably no "property does not exist on ChurchProfileDto" and no missing-module error for `kendo-dropdownlist`).
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/disbursement/models/disbursement.model.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html
git commit -m "feat(church-profile): AI 設定 tab (provider/model/key) with masked keys"
```
---
### Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full backend test suite**
```bash
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
```
Expected: all tests pass (including the 4 ChurchProfileService + 2 factory tests added here, and no regressions in existing AI/expense tests).
- [ ] **Step 2: Confirm the full API + APP build**
```bash
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
cd APP && npx ng build && cd ..
```
Expected: both succeed, 0 errors.
- [ ] **Step 3 (optional, manual): smoke-test the tab**
Run the API + `ng serve` (per the build-run memory: temporary `apiUrl` repoint + `--port 4200`), log in as `super_admin`, open Church Profile → **AI 設定**, set provider + a model + paste a key, Save. Reload: the key field shows the masked placeholder (`••••••…`) and the model/provider persist. Then file an expense and use AI assist to confirm the DB-stored key drives the call.
- [ ] **Step 4: Final commit (if any verification fixups were needed)**
```bash
git add -A
git commit -m "test(church-profile): verify AI settings end-to-end"
```
---
## Notes / accepted behavior
- **DB-only migration consequence:** after the migration runs, `ClaudeApiKey`/`GeminiApiKey` are empty, so AI assist is disabled until an admin enters a key in the new tab. This is the explicitly chosen behavior (spec decision 3, option B).
- **appsettings AI config is now dead** (`Ai` / `Claude` / `Gemini` sections). Left inert per spec "out of scope"; can be removed in a separate cleanup.
- **No "clear key" in v1** — the UI can replace a key but not blank an existing one.
- **`BaseUrl` / `AnthropicVersion`** live as constants in the service classes; changing an endpoint/proxy needs a code edit (accepted).