Add implementation plan: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||||
Reference in New Issue
Block a user