Add design spec: Church Profile AI Settings tab

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 12:55:39 -07:00
parent bdccb79029
commit 5448a9ff85
@@ -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).