Files
ROLAC/docs/superpowers/specs/2026-06-25-church-ai-settings-design.md
Chris Chen 5448a9ff85 Add design spec: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:55:39 -07:00

5.8 KiB

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).