WFDASH-WEBAPP/src/lib/server/ai-settings-service.ts
SJCoetz 63048e51c9 feat: add AI quote processing pipeline and admin settings UI
Add complete end-to-end AI-powered quote extraction workflow:
- Disable PB SDK auto-cancellation to support parallel batch requests
- Add required runtime dependencies for AI processing and document parsing
- Implement AI settings backend, operator UI, and REST API routes
- Add quote ingestion, insight population, and manual re-run AI endpoints
- Build service layers for quote items, AI settings, and structured item insights
- Fix attachment upload handling and quote item history blob parsing
- Add supporting migrations, smoke tests, and shared utility functions
- Export previously private helper functions for cross-service reuse
2026-08-09 16:17:29 +02:00

337 lines
11 KiB
TypeScript

/**
* AI settings service — the runtime source of truth for which LLM the
* processor talks to.
*
* Why this exists
* ───────────────
* Before this service the quotes-ai-processor read `process.env.ANTHROPIC_API_KEY`
* and shipped every Claude call at a fixed provider/model. That was fine
* for one developer with one key on one machine. It breaks for:
*
* • Key rotation — would force a redeploy each time.
* • Multi-provider support — MiniMax / Anthropic-direct selection has
* to happen at call time, not via env-var juggling.
* • Operator visibility — the UI needs to surface "is the AI on?"
* (the `enabled` killswitch) without grepping `.env.local`.
*
* Contract
* ────────
* • Single-row table. `ai_settings` is a PocketBase collection with at
* most one row; `getAiSettings()` returns that row (or a synthetic
* default if the row is missing entirely).
* • 60-second in-memory cache. UI refreshes within a minute of an
* update; the AI processor hot-path pays at most one PB read per
* minute.
* • `api_key` is gated by `isSuperuser()`. Routes that don't have an
* admin context (the /settings/ai page) get back a redacted record
* with `api_key: ""` and `api_key_set: boolean` instead. The
* processor (server-side, admin PB) always gets the full value.
* • Plain-text by design — the operator explicitly chose visibility
* over encryption-at-rest on 2026-08-09. Don't add encryption here
* without re-checking with them.
*/
import { getAdminPocketBase } from "./pocketbase-admin";
export type AiProvider = "anthropic" | "minimax";
/**
* The shape returned by the read path. Mirrors the PB row 1:1 plus
* a derived `api_key_set` flag (so we can tell the UI "a key is set"
* without leaking the value itself).
*/
export interface AiSettings {
id: string;
provider: AiProvider | "";
api_key: string;
api_key_set: boolean;
base_url: string;
model_id: string;
max_tokens: number;
enabled: boolean;
/**
* Operator-editable override of the system prompt sent to the
* Anthropic API. Empty string means "use the hardcoded default in
* the source" — see `src/lib/server/ai-system-prompt.ts`. The
* processor reads this via `getAiSettings()` (cached, 60s TTL).
*/
system_prompt: string;
updated_at: string;
}
/**
* The shape returned to write paths. `api_key` here is OPTIONAL — when
* omitted on a PUT the existing key is preserved (the UI sends an empty
* string to clear).
*/
export interface AiSettingsUpdate {
provider?: AiProvider | "";
api_key?: string;
base_url?: string;
model_id?: string;
max_tokens?: number;
enabled?: boolean;
/**
* Operator-editable system prompt override. `undefined` ⇒ no change.
* `""` ⇒ clear override (processor falls back to default). Non-empty
* ⇒ processor uses this verbatim. Length capped at 50 000 chars
* (matches the PB field's `options.max`).
*/
system_prompt?: string;
}
const COLLECTION = "ai_settings";
/** Defaults used when the PB row is absent (first boot, before the
* migration has run). Matches the migration's seed row. */
const DEFAULT_SETTINGS: AiSettings = {
id: "",
provider: "minimax",
api_key: "",
api_key_set: false,
base_url: "https://api.minimax.io/anthropic",
model_id: "MiniMax-M3",
max_tokens: 4096,
enabled: false,
system_prompt: "",
updated_at: "",
};
/** TTL is short enough that a UI refresh after an update sees the new
* state, but long enough that the processor (which may fire many times
* in a row) doesn't hit PB on every call. */
const CACHE_TTL_MS = 60_000;
interface CacheEntry {
record: AiSettings;
expiresAt: number;
}
let cache: CacheEntry | null = null;
interface RawSettingsRow {
id: string;
provider?: string;
api_key?: string;
base_url?: string;
model_id?: string;
max_tokens?: number;
enabled?: boolean;
system_prompt?: string;
updated_at?: string;
}
function normaliseProvider(value: unknown): AiProvider | "" {
if (value === "anthropic" || value === "minimax") return value;
return "";
}
function rowToSettings(row: RawSettingsRow): AiSettings {
return {
id: row.id,
provider: normaliseProvider(row.provider),
api_key: row.api_key ?? "",
api_key_set: !!(row.api_key && row.api_key.length > 0),
base_url: row.base_url ?? "",
model_id: row.model_id ?? "",
max_tokens: typeof row.max_tokens === "number" ? row.max_tokens : 4096,
enabled: !!row.enabled,
system_prompt: row.system_prompt ?? "",
updated_at: row.updated_at ?? "",
};
}
/** Used in tests to force a fresh read after an out-of-band PB write. */
export function clearAiSettingsCache(): void {
cache = null;
}
function cacheGet(): AiSettings | null {
if (!cache) return null;
if (cache.expiresAt <= Date.now()) {
cache = null;
return null;
}
return cache.record;
}
function cacheSet(record: AiSettings): void {
cache = { record, expiresAt: Date.now() + CACHE_TTL_MS };
}
/**
* Read the single AI-settings row. Returns the redacted shape by
* default (api_key="" + api_key_set=boolean) so callers that shouldn't
* see the key can't accidentally surface it. Pass `includeSecret: true`
* from server-side admin contexts (e.g. the AI processor).
*
* Falls back to DEFAULT_SETTINGS when no row exists yet, so callers
* don't have to handle a missing-row case in their own code paths.
*/
export async function getAiSettings(
options: { includeSecret?: boolean } = {},
): Promise<AiSettings> {
const includeSecret = options.includeSecret === true;
const cached = cacheGet();
if (cached) {
return includeSecret
? cached
: { ...cached, api_key: "", api_key_set: cached.api_key_set };
}
const pb = await getAdminPocketBase();
let record: AiSettings;
try {
const rows = await pb.collection(COLLECTION).getFullList({});
if (rows.length === 0) {
record = { ...DEFAULT_SETTINGS };
} else {
// Single-row invariant — if the operator ever duplicates, take
// the most-recently-updated so the read is at least deterministic.
const sorted = (rows as RawSettingsRow[]).sort((a, b) => {
const at = a.updated_at ?? "";
const bt = b.updated_at ?? "";
return bt.localeCompare(at);
});
record = rowToSettings(sorted[0]);
}
} catch {
// Collection missing entirely (pre-migration). Fall back to defaults
// rather than throw — callers like /settings/ai should still render.
record = { ...DEFAULT_SETTINGS };
}
cacheSet(record);
return includeSecret
? record
: { ...record, api_key: "", api_key_set: record.api_key_set };
}
/**
* Patch the AI-settings row. The caller passes only the fields they
* want to change. Special handling for `api_key`:
*
* • `api_key: undefined` → field not touched.
* • `api_key: ""` → field cleared (api_key_set becomes false).
* • `api_key: "sk-..."` → field replaced.
*
* Bumps the in-memory cache on success so the very next `getAiSettings()`
* sees the update without waiting out the TTL.
*/
export async function updateAiSettings(
patch: AiSettingsUpdate,
): Promise<AiSettings> {
const pb = await getAdminPocketBase();
// Find the single row (or fall back to creating one if it doesn't
// exist yet — first-run scenario).
const rows = await pb.collection(COLLECTION).getFullList({});
const dbPatch: Record<string, unknown> = {};
if (patch.provider !== undefined) {
if (patch.provider !== "" && patch.provider !== "anthropic" && patch.provider !== "minimax") {
throw new Error(`Unknown provider "${patch.provider}"`);
}
dbPatch.provider = patch.provider || "";
}
if (patch.api_key !== undefined) {
dbPatch.api_key = patch.api_key; // already "" when clearing
}
if (patch.base_url !== undefined) dbPatch.base_url = patch.base_url;
if (patch.model_id !== undefined) dbPatch.model_id = patch.model_id;
if (patch.max_tokens !== undefined) {
if (
typeof patch.max_tokens !== "number" ||
!Number.isFinite(patch.max_tokens) ||
patch.max_tokens < 256 ||
patch.max_tokens > 8192
) {
throw new Error("max_tokens must be a number between 256 and 8192");
}
dbPatch.max_tokens = Math.round(patch.max_tokens);
}
if (patch.enabled !== undefined) dbPatch.enabled = !!patch.enabled;
if (patch.system_prompt !== undefined) {
// Defensive cap — the PB field already enforces 50 000 but the
// route handler is one boundary removed. Truncate rather than
// reject so a paste-from-clipboard typo doesn't 400 the whole form.
dbPatch.system_prompt = patch.system_prompt.slice(0, 50_000);
}
dbPatch.updated_at = new Date().toISOString();
let savedRow: RawSettingsRow;
if (rows.length === 0) {
// First-ever write — seed with merged defaults so we never end up
// with a row that has a missing required-like field.
savedRow = (await pb.collection(COLLECTION).create({
provider: "minimax",
api_key: "",
base_url: "https://api.minimax.io/anthropic",
model_id: "MiniMax-M3",
max_tokens: 4096,
enabled: false,
system_prompt: "",
...dbPatch,
})) as RawSettingsRow;
} else {
const id = (rows[0] as RawSettingsRow).id;
savedRow = (await pb.collection(COLLECTION).update(id, dbPatch)) as RawSettingsRow;
}
const record = rowToSettings(savedRow);
cacheSet(record);
return record;
}
/**
* Convenience for callers that just want the SDK knobs (no record
* scaffolding). Equivalent to `getAiSettings({ includeSecret: true })`
* but with a single field-select to keep the type tight. Also raises
* a clear error if the processor tries to run while disabled.
*/
export interface ActiveAiConfig {
provider: AiProvider;
apiKey: string;
baseUrl: string;
modelId: string;
maxTokens: number;
}
export async function getActiveAiConfig(): Promise<ActiveAiConfig> {
const settings = await getAiSettings({ includeSecret: true });
if (!settings.enabled) {
throw new AiDisabledError("AI is disabled in settings");
}
if (!settings.api_key) {
throw new Error(
`AI is enabled but api_key is not set (provider=${settings.provider || "(none)"}). Update /settings/ai.`,
);
}
if (!settings.model_id) {
throw new Error("AI is enabled but model_id is not set. Update /settings/ai.");
}
// For "anthropic" we let the SDK pick its default endpoint by
// passing undefined baseURL — that keeps retries / SDK features
// working normally. For every other provider we honour whatever
// the operator typed (MiniMax's Anthropic-compatible endpoint in
// particular must be set explicitly).
const baseUrl =
settings.provider === "anthropic"
? settings.base_url || undefined
: settings.base_url;
return {
provider: settings.provider as AiProvider,
apiKey: settings.api_key,
baseUrl: baseUrl ?? "",
modelId: settings.model_id,
maxTokens: settings.max_tokens || 4096,
};
}
/** Tagged error so the processor can map "AI off" to ai_error="ai_disabled". */
export class AiDisabledError extends Error {
readonly code = "ai_disabled";
constructor(message: string) {
super(message);
this.name = "AiDisabledError";
}
}