5448a9ff85
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
5.8 KiB
Markdown
113 lines
5.8 KiB
Markdown
# 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).
|