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
337 lines
11 KiB
TypeScript
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";
|
|
}
|
|
}
|