From 5448a9ff856e58827ebd634d284a6897c09655d9 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 12:55:39 -0700 Subject: [PATCH] Add design spec: Church Profile AI Settings tab Co-Authored-By: Claude Opus 4.8 --- .../2026-06-25-church-ai-settings-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-church-ai-settings-design.md diff --git a/docs/superpowers/specs/2026-06-25-church-ai-settings-design.md b/docs/superpowers/specs/2026-06-25-church-ai-settings-design.md new file mode 100644 index 0000000..7453076 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-church-ai-settings-design.md @@ -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` / `IOptions`. 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).