From 82b261c7bcaaa6bca4f28f19ce4cc51bd9c7f21c Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:48:28 +0000 Subject: [PATCH] Refactor LLM service into modular adapters and policies (#83) * llm migration * orchestrator runer * Decompose runPipeline steps * dedupe * refactor(settings): unify settings conversion metadata and round-trip tests * refactor(llm): extract shared provider strategy factory * refactor(settings-ui): add reusable numeric setting section * test(orchestrator): stabilize usePipelineSources localStorage setup * comments --- .../orchestrator/usePipelineSources.test.ts | 53 +- .../components/GradcrackerSection.tsx | 72 +- .../components/NumericSettingSection.test.tsx | 49 + .../components/NumericSettingSection.tsx | 91 ++ .../settings/components/UkvisajobsSection.tsx | 68 +- .../src/server/api/routes/settings.ts | 339 +------ .../src/server/pipeline/orchestrator.ts | 360 +------- .../pipeline/steps/discover-jobs.test.ts | 99 ++ .../server/pipeline/steps/discover-jobs.ts | 158 ++++ .../src/server/pipeline/steps/import-jobs.ts | 17 + .../src/server/pipeline/steps/index.ts | 8 + .../src/server/pipeline/steps/load-profile.ts | 12 + .../server/pipeline/steps/notify-webhook.ts | 43 + .../src/server/pipeline/steps/process-jobs.ts | 39 + .../src/server/pipeline/steps/score-jobs.ts | 79 ++ .../server/pipeline/steps/select-jobs.test.ts | 32 + .../src/server/pipeline/steps/select-jobs.ts | 18 + .../src/server/pipeline/steps/types.ts | 19 + .../src/server/services/llm-service.ts | 846 +----------------- .../llm/policies/capability-fallback.test.ts | 34 + .../llm/policies/capability-fallback.ts | 25 + .../llm/policies/mode-selection.test.ts | 48 + .../services/llm/policies/mode-selection.ts | 28 + .../llm/policies/retry-policy.test.ts | 42 + .../services/llm/policies/retry-policy.ts | 16 + .../server/services/llm/providers/factory.ts | 72 ++ .../server/services/llm/providers/gemini.ts | 89 ++ .../server/services/llm/providers/index.ts | 14 + .../server/services/llm/providers/lmstudio.ts | 22 + .../server/services/llm/providers/ollama.ts | 22 + .../server/services/llm/providers/openai.ts | 76 ++ .../services/llm/providers/openrouter.ts | 28 + .../services/llm/providers/providers.test.ts | 137 +++ .../src/server/services/llm/service.ts | 283 ++++++ orchestrator/src/server/services/llm/types.ts | 87 ++ .../src/server/services/llm/utils/http.ts | 51 ++ .../src/server/services/llm/utils/json.ts | 27 + .../src/server/services/llm/utils/object.ts | 19 + .../src/server/services/llm/utils/string.ts | 37 + .../services/settings-conversion.test.ts | 81 ++ .../server/services/settings-conversion.ts | 223 +++++ .../settings-update/apply-updates.test.ts | 149 +++ .../services/settings-update/apply-updates.ts | 46 + .../server/services/settings-update/index.ts | 16 + .../services/settings-update/registry.ts | 329 +++++++ orchestrator/src/server/services/settings.ts | 213 +++-- 46 files changed, 2882 insertions(+), 1734 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx create mode 100644 orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx create mode 100644 orchestrator/src/server/pipeline/steps/discover-jobs.test.ts create mode 100644 orchestrator/src/server/pipeline/steps/discover-jobs.ts create mode 100644 orchestrator/src/server/pipeline/steps/import-jobs.ts create mode 100644 orchestrator/src/server/pipeline/steps/index.ts create mode 100644 orchestrator/src/server/pipeline/steps/load-profile.ts create mode 100644 orchestrator/src/server/pipeline/steps/notify-webhook.ts create mode 100644 orchestrator/src/server/pipeline/steps/process-jobs.ts create mode 100644 orchestrator/src/server/pipeline/steps/score-jobs.ts create mode 100644 orchestrator/src/server/pipeline/steps/select-jobs.test.ts create mode 100644 orchestrator/src/server/pipeline/steps/select-jobs.ts create mode 100644 orchestrator/src/server/pipeline/steps/types.ts create mode 100644 orchestrator/src/server/services/llm/policies/capability-fallback.test.ts create mode 100644 orchestrator/src/server/services/llm/policies/capability-fallback.ts create mode 100644 orchestrator/src/server/services/llm/policies/mode-selection.test.ts create mode 100644 orchestrator/src/server/services/llm/policies/mode-selection.ts create mode 100644 orchestrator/src/server/services/llm/policies/retry-policy.test.ts create mode 100644 orchestrator/src/server/services/llm/policies/retry-policy.ts create mode 100644 orchestrator/src/server/services/llm/providers/factory.ts create mode 100644 orchestrator/src/server/services/llm/providers/gemini.ts create mode 100644 orchestrator/src/server/services/llm/providers/index.ts create mode 100644 orchestrator/src/server/services/llm/providers/lmstudio.ts create mode 100644 orchestrator/src/server/services/llm/providers/ollama.ts create mode 100644 orchestrator/src/server/services/llm/providers/openai.ts create mode 100644 orchestrator/src/server/services/llm/providers/openrouter.ts create mode 100644 orchestrator/src/server/services/llm/providers/providers.test.ts create mode 100644 orchestrator/src/server/services/llm/service.ts create mode 100644 orchestrator/src/server/services/llm/types.ts create mode 100644 orchestrator/src/server/services/llm/utils/http.ts create mode 100644 orchestrator/src/server/services/llm/utils/json.ts create mode 100644 orchestrator/src/server/services/llm/utils/object.ts create mode 100644 orchestrator/src/server/services/llm/utils/string.ts create mode 100644 orchestrator/src/server/services/settings-conversion.test.ts create mode 100644 orchestrator/src/server/services/settings-conversion.ts create mode 100644 orchestrator/src/server/services/settings-update/apply-updates.test.ts create mode 100644 orchestrator/src/server/services/settings-update/apply-updates.ts create mode 100644 orchestrator/src/server/services/settings-update/index.ts create mode 100644 orchestrator/src/server/services/settings-update/registry.ts diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts b/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts index a54a443..0459721 100644 --- a/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts +++ b/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts @@ -4,13 +4,58 @@ import { beforeEach, describe, expect, it } from "vitest"; import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants"; import { usePipelineSources } from "./usePipelineSources"; +function ensureStorage(): Storage { + const existing = globalThis.localStorage as Partial | undefined; + const hasStorageShape = + existing && + typeof existing.getItem === "function" && + typeof existing.setItem === "function" && + typeof existing.removeItem === "function" && + typeof existing.clear === "function"; + + if (hasStorageShape) { + return existing as Storage; + } + + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + const value = store.get(key); + return value ?? null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + }; + + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + writable: true, + }); + + return storage; +} + describe("usePipelineSources", () => { beforeEach(() => { - localStorage.clear(); + ensureStorage().clear(); }); it("filters stored sources to enabled sources", () => { - localStorage.setItem( + ensureStorage().setItem( PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker", "ukvisajobs"]), ); @@ -23,7 +68,7 @@ describe("usePipelineSources", () => { }); it("falls back to the first enabled source", () => { - localStorage.setItem( + ensureStorage().setItem( PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["ukvisajobs"]), ); @@ -36,7 +81,7 @@ describe("usePipelineSources", () => { }); it("ignores toggles for disabled sources", () => { - localStorage.setItem( + ensureStorage().setItem( PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker"]), ); diff --git a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx index 60f2126..24bd104 100644 --- a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx +++ b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx @@ -1,13 +1,6 @@ -import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import type { NumericSettingValues } from "@client/pages/settings/types"; -import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type React from "react"; -import { Controller, useFormContext } from "react-hook-form"; -import { - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; +import { NumericSettingSection } from "./NumericSettingSection"; type GradcrackerSectionProps = { values: NumericSettingValues; @@ -20,57 +13,18 @@ export const GradcrackerSection: React.FC = ({ isLoading, isSaving, }) => { - const { - effective: effectiveGradcrackerMaxJobsPerTerm, - default: defaultGradcrackerMaxJobsPerTerm, - } = values; - const { - control, - formState: { errors }, - } = useFormContext(); - return ( - - - Gradcracker Extractor - - -
- ( - { - const value = parseInt(event.target.value, 10); - if (Number.isNaN(value)) { - field.onChange(null); - } else { - field.onChange(Math.min(1000, Math.max(1, value))); - } - }, - }} - disabled={isLoading || isSaving} - error={ - errors.gradcrackerMaxJobsPerTerm?.message as - | string - | undefined - } - helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`} - current={String(effectiveGradcrackerMaxJobsPerTerm)} - /> - )} - /> -
-
-
+ ); }; diff --git a/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx b/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx new file mode 100644 index 0000000..cb27bf3 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx @@ -0,0 +1,49 @@ +import type { UpdateSettingsInput } from "@shared/settings-schema.js"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; +import { Accordion } from "@/components/ui/accordion"; +import { NumericSettingSection } from "./NumericSettingSection"; + +const Harness = () => { + const methods = useForm({ + defaultValues: { + ukvisajobsMaxJobs: 50, + }, + }); + + return ( + + + + + + ); +}; + +describe("NumericSettingSection", () => { + it("clamps out-of-range values and clears invalid number input", () => { + render(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "1001" } }); + expect(input).toHaveValue(1000); + + fireEvent.change(input, { target: { value: "0" } }); + expect(input).toHaveValue(1); + + fireEvent.change(input, { target: { value: "" } }); + expect(input).toHaveValue(50); + }); +}); diff --git a/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx b/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx new file mode 100644 index 0000000..f165478 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx @@ -0,0 +1,91 @@ +import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; +import type { NumericSettingValues } from "@client/pages/settings/types"; +import type { UpdateSettingsInput } from "@shared/settings-schema.js"; +import type React from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +type NumericFieldName = + | "ukvisajobsMaxJobs" + | "gradcrackerMaxJobsPerTerm" + | "jobspyResultsWanted" + | "jobspyHoursOld" + | "backupHour" + | "backupMaxCount"; + +type NumericSettingSectionProps = { + accordionValue: string; + title: string; + fieldName: NumericFieldName; + label: string; + helper: string; + values: NumericSettingValues; + min: number; + max: number; + isLoading: boolean; + isSaving: boolean; +}; + +export const NumericSettingSection: React.FC = ({ + accordionValue, + title, + fieldName, + label, + helper, + values, + min, + max, + isLoading, + isSaving, +}) => { + const { effective, default: defaultValue } = values; + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + {title} + + +
+ ( + { + const parsed = parseInt(event.target.value, 10); + if (Number.isNaN(parsed)) { + field.onChange(null); + return; + } + field.onChange(Math.min(max, Math.max(min, parsed))); + }, + }} + disabled={isLoading || isSaving} + error={errors[fieldName]?.message as string | undefined} + helper={helper} + current={String(effective)} + /> + )} + /> +
+
+
+ ); +}; diff --git a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx index f95d028..d995546 100644 --- a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx @@ -1,13 +1,6 @@ -import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import type { NumericSettingValues } from "@client/pages/settings/types"; -import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type React from "react"; -import { Controller, useFormContext } from "react-hook-form"; -import { - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; +import { NumericSettingSection } from "./NumericSettingSection"; type UkvisajobsSectionProps = { values: NumericSettingValues; @@ -20,53 +13,18 @@ export const UkvisajobsSection: React.FC = ({ isLoading, isSaving, }) => { - const { - effective: effectiveUkvisajobsMaxJobs, - default: defaultUkvisajobsMaxJobs, - } = values; - const { - control, - formState: { errors }, - } = useFormContext(); - return ( - - - UKVisaJobs Extractor - - -
- ( - { - const value = parseInt(event.target.value, 10); - if (Number.isNaN(value)) { - field.onChange(null); - } else { - field.onChange(Math.min(1000, Math.max(1, value))); - } - }, - }} - disabled={isLoading || isSaving} - error={errors.ukvisajobsMaxJobs?.message as string | undefined} - helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`} - current={String(effectiveUkvisajobsMaxJobs)} - /> - )} - /> -
-
-
+ ); }; diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 4f03aa8..dbc3fe1 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -1,17 +1,12 @@ -import * as settingsRepo from "@server/repositories/settings"; import { setBackupSettings } from "@server/services/backup/index"; -import { applyEnvValue, normalizeEnvInput } from "@server/services/envSettings"; -import { getProfile } from "@server/services/profile"; -import { - extractProjectsFromProfile, - normalizeResumeProjectsSettings, -} from "@server/services/resumeProjects"; +import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { getResume, listResumes, RxResumeCredentialsError, } from "@server/services/rxresume-v4"; import { getEffectiveSettings } from "@server/services/settings"; +import { applySettingsUpdates } from "@server/services/settings-update"; import { updateSettingsSchema } from "@shared/settings-schema"; import { type Request, type Response, Router } from "express"; @@ -36,337 +31,11 @@ settingsRouter.get("/", async (_req: Request, res: Response) => { settingsRouter.patch("/", async (req: Request, res: Response) => { try { const input = updateSettingsSchema.parse(req.body); - const promises: Promise[] = []; - - if ("model" in input) { - promises.push(settingsRepo.setSetting("model", input.model ?? null)); - } - - if ("modelScorer" in input) { - promises.push( - settingsRepo.setSetting("modelScorer", input.modelScorer ?? null), - ); - } - if ("modelTailoring" in input) { - promises.push( - settingsRepo.setSetting("modelTailoring", input.modelTailoring ?? null), - ); - } - if ("modelProjectSelection" in input) { - promises.push( - settingsRepo.setSetting( - "modelProjectSelection", - input.modelProjectSelection ?? null, - ), - ); - } - - if ("llmProvider" in input) { - const value = normalizeEnvInput(input.llmProvider); - promises.push( - settingsRepo.setSetting("llmProvider", value).then(() => { - applyEnvValue("LLM_PROVIDER", value); - }), - ); - } - - if ("llmBaseUrl" in input) { - const value = normalizeEnvInput(input.llmBaseUrl); - promises.push( - settingsRepo.setSetting("llmBaseUrl", value).then(() => { - applyEnvValue("LLM_BASE_URL", value); - }), - ); - } - - if ("pipelineWebhookUrl" in input) { - promises.push( - settingsRepo.setSetting( - "pipelineWebhookUrl", - input.pipelineWebhookUrl ?? null, - ), - ); - } - - if ("jobCompleteWebhookUrl" in input) { - promises.push( - settingsRepo.setSetting( - "jobCompleteWebhookUrl", - input.jobCompleteWebhookUrl ?? null, - ), - ); - } - - if ("rxresumeBaseResumeId" in input) { - promises.push( - settingsRepo.setSetting( - "rxresumeBaseResumeId", - normalizeEnvInput(input.rxresumeBaseResumeId), - ), - ); - } - - if ("resumeProjects" in input) { - const resumeProjects = input.resumeProjects ?? null; - - if (resumeProjects === null) { - promises.push(settingsRepo.setSetting("resumeProjects", null)); - } else { - promises.push( - (async () => { - // getProfile() will fetch from RxResume v4 API using rxresumeBaseResumeId - const profile = await getProfile(); - const { catalog } = extractProjectsFromProfile(profile); - const allowed = new Set(catalog.map((p) => p.id)); - const normalized = normalizeResumeProjectsSettings( - resumeProjects, - allowed, - ); - await settingsRepo.setSetting( - "resumeProjects", - JSON.stringify(normalized), - ); - })(), - ); - } - } - - if ("ukvisajobsMaxJobs" in input) { - const val = input.ukvisajobsMaxJobs ?? null; - promises.push( - settingsRepo.setSetting( - "ukvisajobsMaxJobs", - val !== null ? String(val) : null, - ), - ); - } - - if ("gradcrackerMaxJobsPerTerm" in input) { - const val = input.gradcrackerMaxJobsPerTerm ?? null; - promises.push( - settingsRepo.setSetting( - "gradcrackerMaxJobsPerTerm", - val !== null ? String(val) : null, - ), - ); - } - - if ("searchTerms" in input) { - const val = input.searchTerms ?? null; - promises.push( - settingsRepo.setSetting( - "searchTerms", - val !== null ? JSON.stringify(val) : null, - ), - ); - } - - if ("jobspyLocation" in input) { - promises.push( - settingsRepo.setSetting("jobspyLocation", input.jobspyLocation ?? null), - ); - } - - if ("jobspyResultsWanted" in input) { - const val = input.jobspyResultsWanted ?? null; - promises.push( - settingsRepo.setSetting( - "jobspyResultsWanted", - val !== null ? String(val) : null, - ), - ); - } - - if ("jobspyHoursOld" in input) { - const val = input.jobspyHoursOld ?? null; - promises.push( - settingsRepo.setSetting( - "jobspyHoursOld", - val !== null ? String(val) : null, - ), - ); - } - - if ("jobspyCountryIndeed" in input) { - promises.push( - settingsRepo.setSetting( - "jobspyCountryIndeed", - input.jobspyCountryIndeed ?? null, - ), - ); - } - - if ("jobspySites" in input) { - const val = input.jobspySites ?? null; - promises.push( - settingsRepo.setSetting( - "jobspySites", - val !== null ? JSON.stringify(val) : null, - ), - ); - } - - if ("jobspyLinkedinFetchDescription" in input) { - const val = input.jobspyLinkedinFetchDescription ?? null; - promises.push( - settingsRepo.setSetting( - "jobspyLinkedinFetchDescription", - val !== null ? (val ? "1" : "0") : null, - ), - ); - } - - if ("jobspyIsRemote" in input) { - const val = input.jobspyIsRemote ?? null; - promises.push( - settingsRepo.setSetting( - "jobspyIsRemote", - val !== null ? (val ? "1" : "0") : null, - ), - ); - } - - if ("showSponsorInfo" in input) { - const val = input.showSponsorInfo ?? null; - promises.push( - settingsRepo.setSetting( - "showSponsorInfo", - val !== null ? (val ? "1" : "0") : null, - ), - ); - } - - if ("openrouterApiKey" in input) { - // @deprecated Use llmApiKey. Keep accepting this field for backwards compatibility. - console.warn( - "[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.", - ); - const value = normalizeEnvInput(input.openrouterApiKey); - promises.push( - settingsRepo.setSetting("llmApiKey", value).then(() => { - applyEnvValue("LLM_API_KEY", value); - }), - ); - promises.push( - settingsRepo.setSetting("openrouterApiKey", null).then(() => { - applyEnvValue("OPENROUTER_API_KEY", null); - }), - ); - } - - if ("llmApiKey" in input) { - const value = normalizeEnvInput(input.llmApiKey); - promises.push( - settingsRepo.setSetting("llmApiKey", value).then(() => { - applyEnvValue("LLM_API_KEY", value); - }), - ); - } - - if ("rxresumeEmail" in input) { - const value = normalizeEnvInput(input.rxresumeEmail); - promises.push( - settingsRepo.setSetting("rxresumeEmail", value).then(() => { - applyEnvValue("RXRESUME_EMAIL", value); - }), - ); - } - - if ("rxresumePassword" in input) { - const value = normalizeEnvInput(input.rxresumePassword); - promises.push( - settingsRepo.setSetting("rxresumePassword", value).then(() => { - applyEnvValue("RXRESUME_PASSWORD", value); - }), - ); - } - - if ("basicAuthUser" in input) { - const value = normalizeEnvInput(input.basicAuthUser); - promises.push( - settingsRepo.setSetting("basicAuthUser", value).then(() => { - applyEnvValue("BASIC_AUTH_USER", value); - }), - ); - } - - if ("basicAuthPassword" in input) { - const value = normalizeEnvInput(input.basicAuthPassword); - promises.push( - settingsRepo.setSetting("basicAuthPassword", value).then(() => { - applyEnvValue("BASIC_AUTH_PASSWORD", value); - }), - ); - } - - if ("ukvisajobsEmail" in input) { - const value = normalizeEnvInput(input.ukvisajobsEmail); - promises.push( - settingsRepo.setSetting("ukvisajobsEmail", value).then(() => { - applyEnvValue("UKVISAJOBS_EMAIL", value); - }), - ); - } - - if ("ukvisajobsPassword" in input) { - const value = normalizeEnvInput(input.ukvisajobsPassword); - promises.push( - settingsRepo.setSetting("ukvisajobsPassword", value).then(() => { - applyEnvValue("UKVISAJOBS_PASSWORD", value); - }), - ); - } - - if ("webhookSecret" in input) { - const value = normalizeEnvInput(input.webhookSecret); - promises.push( - settingsRepo.setSetting("webhookSecret", value).then(() => { - applyEnvValue("WEBHOOK_SECRET", value); - }), - ); - } - - // Backup settings - if ("backupEnabled" in input) { - const val = input.backupEnabled ?? null; - promises.push( - settingsRepo.setSetting( - "backupEnabled", - val !== null ? (val ? "1" : "0") : null, - ), - ); - } - - if ("backupHour" in input) { - const val = input.backupHour ?? null; - promises.push( - settingsRepo.setSetting( - "backupHour", - val !== null ? String(val) : null, - ), - ); - } - - if ("backupMaxCount" in input) { - const val = input.backupMaxCount ?? null; - promises.push( - settingsRepo.setSetting( - "backupMaxCount", - val !== null ? String(val) : null, - ), - ); - } - - await Promise.all(promises); + const plan = await applySettingsUpdates(input); const data = await getEffectiveSettings(); - // Update backup scheduler if backup settings changed - if ( - "backupEnabled" in input || - "backupHour" in input || - "backupMaxCount" in input - ) { + if (plan.shouldRefreshBackupScheduler) { setBackupSettings({ enabled: data.backupEnabled, hour: data.backupHour, diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 63c5504..865a8cf 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -8,14 +8,11 @@ */ import { join } from "node:path"; -import type { CreateJobInput, Job, PipelineConfig } from "@shared/types"; +import type { PipelineConfig } from "@shared/types"; import { getDataDir } from "../config/dataDir"; import * as jobsRepo from "../repositories/jobs"; import * as pipelineRepo from "../repositories/pipeline"; -import * as settingsRepo from "../repositories/settings"; import { getSetting } from "../repositories/settings"; -import { runCrawler } from "../services/crawler"; -import { runJobSpy } from "../services/jobspy"; import { generatePdf } from "../services/pdf"; import { getProfile } from "../services/profile"; import { pickProjectIdsForJob } from "../services/projectSelection"; @@ -23,11 +20,17 @@ import { extractProjectsFromProfile, resolveResumeProjectsSettings, } from "../services/resumeProjects"; -import { scoreJobSuitability } from "../services/scorer"; import { generateTailoring } from "../services/summary"; -import { runUkVisaJobs } from "../services/ukvisajobs"; -import * as visaSponsors from "../services/visa-sponsors/index"; -import { progressHelpers, resetProgress, updateProgress } from "./progress"; +import { progressHelpers, resetProgress } from "./progress"; +import { + discoverJobsStep, + importJobsStep, + loadProfileStep, + notifyPipelineWebhookStep, + processJobsStep, + scoreJobsStep, + selectJobsStep, +} from "./steps"; const DEFAULT_CONFIG: PipelineConfig = { topN: 10, @@ -43,47 +46,6 @@ const DEFAULT_CONFIG: PipelineConfig = { // Track if pipeline is currently running let isPipelineRunning = false; -async function notifyPipelineWebhook( - event: "pipeline.completed" | "pipeline.failed", - payload: Record, -) { - const overridePipelineWebhookUrl = - await settingsRepo.getSetting("pipelineWebhookUrl"); - const pipelineWebhookUrl = ( - overridePipelineWebhookUrl || - process.env.PIPELINE_WEBHOOK_URL || - process.env.WEBHOOK_URL || - "" - ).trim(); - if (!pipelineWebhookUrl) return; - - try { - const headers: Record = { - "Content-Type": "application/json", - }; - const secret = process.env.WEBHOOK_SECRET; - if (secret) headers.Authorization = `Bearer ${secret}`; - - const response = await fetch(pipelineWebhookUrl, { - method: "POST", - headers, - body: JSON.stringify({ - event, - sentAt: new Date().toISOString(), - ...payload, - }), - }); - - if (!response.ok) { - console.warn( - `⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`, - ); - } - } catch (error) { - console.warn("⚠️ Pipeline webhook POST failed:", error); - } -} - /** * Run the full job discovery and processing pipeline. */ @@ -108,7 +70,6 @@ export async function runPipeline( resetProgress(); const mergedConfig = { ...DEFAULT_CONFIG, ...config }; - // Create pipeline run record const pipelineRun = await pipelineRepo.createPipelineRun(); console.log("🚀 Starting job pipeline..."); @@ -117,306 +78,33 @@ export async function runPipeline( ); try { - // Step 1: Load profile - console.log("\n📋 Loading profile..."); - const profile = await getProfile().catch((error) => { - console.warn( - "⚠️ Failed to load profile for scoring, using empty profile:", - error, - ); - return {} as Record; - }); + const profile = await loadProfileStep(); - // Step 2: Run crawler - console.log("\n🕷️ Running crawler..."); - progressHelpers.startCrawling(); - const discoveredJobs: CreateJobInput[] = []; - const sourceErrors: string[] = []; + const { discoveredJobs } = await discoverJobsStep({ mergedConfig }); - // Read all settings at once to avoid sequential DB calls - const settings = await settingsRepo.getAllSettings(); - - // Read search terms setting - const searchTermsSetting = settings.searchTerms; - let searchTerms: string[] = []; - - if (searchTermsSetting) { - searchTerms = JSON.parse(searchTermsSetting) as string[]; - } else { - // Default from env var - const defaultSearchTermsEnv = - process.env.JOBSPY_SEARCH_TERMS || "web developer"; - searchTerms = defaultSearchTermsEnv - .split("|") - .map((s) => s.trim()) - .filter(Boolean); - } - - // Run JobSpy (Indeed/LinkedIn) if selected - let jobSpySites = mergedConfig.sources.filter( - (s): s is "indeed" | "linkedin" => s === "indeed" || s === "linkedin", - ); - - // Apply setting override for JobSpy sites - const jobspySitesSettingRaw = settings.jobspySites; - if (jobspySitesSettingRaw) { - try { - const allowed = JSON.parse(jobspySitesSettingRaw); - if (Array.isArray(allowed)) { - jobSpySites = jobSpySites.filter((s) => allowed.includes(s)); - } - } catch { - // ignore JSON parse error - } - } - - if (jobSpySites.length > 0) { - updateProgress({ - step: "crawling", - detail: `JobSpy: scraping ${jobSpySites.join(", ")}...`, - }); - - const jobspyLocationSetting = settings.jobspyLocation; - const jobspyResultsWantedSetting = settings.jobspyResultsWanted; - const jobspyHoursOldSetting = settings.jobspyHoursOld; - const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed; - const jobspyLinkedinFetchDescriptionSetting = - settings.jobspyLinkedinFetchDescription; - const jobspyIsRemoteSetting = settings.jobspyIsRemote; - - const jobSpyResult = await runJobSpy({ - sites: jobSpySites, - searchTerms, - location: jobspyLocationSetting ?? undefined, - resultsWanted: jobspyResultsWantedSetting - ? parseInt(jobspyResultsWantedSetting, 10) - : undefined, - hoursOld: jobspyHoursOldSetting - ? parseInt(jobspyHoursOldSetting, 10) - : undefined, - countryIndeed: jobspyCountryIndeedSetting ?? undefined, - linkedinFetchDescription: - jobspyLinkedinFetchDescriptionSetting !== null && - jobspyLinkedinFetchDescriptionSetting !== undefined - ? jobspyLinkedinFetchDescriptionSetting === "1" - : undefined, - isRemote: - jobspyIsRemoteSetting !== null && jobspyIsRemoteSetting !== undefined - ? jobspyIsRemoteSetting === "1" - : undefined, - }); - if (!jobSpyResult.success) { - sourceErrors.push(`jobspy: ${jobSpyResult.error ?? "unknown error"}`); - } else { - discoveredJobs.push(...jobSpyResult.jobs); - } - } - - // Run Gradcracker crawler if selected - if (mergedConfig.sources.includes("gradcracker")) { - updateProgress({ - step: "crawling", - detail: "Gradcracker: scraping...", - }); - - // Pass existing URLs to avoid clicking "Apply" on jobs we already have - const existingJobUrls = await jobsRepo.getAllJobUrls(); - - const gradcrackerMaxJobsSetting = settings.gradcrackerMaxJobsPerTerm; - const gradcrackerMaxJobs = gradcrackerMaxJobsSetting - ? parseInt(gradcrackerMaxJobsSetting, 10) - : 50; - - const crawlerResult = await runCrawler({ - existingJobUrls, - searchTerms, - maxJobsPerTerm: gradcrackerMaxJobs, - onProgress: (progress) => { - // Calculate overall progress based on list pages processed vs total - // This is rough but better than nothing - if (progress.listPagesTotal && progress.listPagesTotal > 0) { - const percent = Math.round( - ((progress.listPagesProcessed ?? 0) / progress.listPagesTotal) * - 100, - ); - updateProgress({ - step: "crawling", - detail: `Gradcracker: ${percent}% (scan ${progress.listPagesProcessed}/${progress.listPagesTotal}, found ${progress.jobCardsFound})`, - }); - } - }, - }); - - if (!crawlerResult.success) { - sourceErrors.push( - `gradcracker: ${crawlerResult.error ?? "unknown error"}`, - ); - } else { - discoveredJobs.push(...crawlerResult.jobs); - } - } - - // Run UKVisaJobs extractor if selected - if (mergedConfig.sources.includes("ukvisajobs")) { - updateProgress({ - step: "crawling", - detail: "UKVisaJobs: scraping visa-sponsoring jobs...", - }); - - // Read max jobs setting from database (default to 50 if not set) - const ukvisajobsMaxJobsSetting = settings.ukvisajobsMaxJobs; - const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting - ? parseInt(ukvisajobsMaxJobsSetting, 10) - : 50; - - const ukVisaResult = await runUkVisaJobs({ - maxJobs: ukvisajobsMaxJobs, - searchTerms, - }); - if (!ukVisaResult.success) { - sourceErrors.push( - `ukvisajobs: ${ukVisaResult.error ?? "unknown error"}`, - ); - } else { - discoveredJobs.push(...ukVisaResult.jobs); - } - } - - if (discoveredJobs.length === 0 && sourceErrors.length > 0) { - throw new Error(`All sources failed: ${sourceErrors.join("; ")}`); - } - - if (sourceErrors.length > 0) { - console.warn(`ƒsÿ‹,? Some sources failed: ${sourceErrors.join("; ")}`); - } - - progressHelpers.crawlingComplete(discoveredJobs.length); - - // Step 3: Import discovered jobs - console.log("\n💾 Importing jobs to database..."); - const { created, skipped } = await jobsRepo.bulkCreateJobs(discoveredJobs); - console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`); - - progressHelpers.importComplete(created, skipped); + const { created } = await importJobsStep({ discoveredJobs }); await pipelineRepo.updatePipelineRun(pipelineRun.id, { jobsDiscovered: created, }); - // Step 4: Score all discovered jobs missing a score - console.log("\n🎯 Scoring jobs for suitability..."); - const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(); + const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile }); - updateProgress({ - step: "scoring", - jobsDiscovered: unprocessedJobs.length, - jobsScored: 0, - jobsProcessed: 0, - totalToProcess: 0, - currentJob: undefined, + const jobsToProcess = selectJobsStep({ + scoredJobs, + mergedConfig, }); - // Score jobs with progress updates - const scoredJobs: Array< - Job & { suitabilityScore: number; suitabilityReason: string } - > = []; - for (let i = 0; i < unprocessedJobs.length; i++) { - const job = unprocessedJobs[i]; - const hasCachedScore = - typeof job.suitabilityScore === "number" && - !Number.isNaN(job.suitabilityScore); - progressHelpers.scoringJob( - i + 1, - unprocessedJobs.length, - hasCachedScore ? `${job.title} (cached)` : job.title, - ); - - if (hasCachedScore) { - scoredJobs.push({ - ...job, - suitabilityScore: job.suitabilityScore as number, - suitabilityReason: job.suitabilityReason ?? "", - }); - continue; - } - - const { score, reason } = await scoreJobSuitability(job, profile); - scoredJobs.push({ - ...job, - suitabilityScore: score, - suitabilityReason: reason, - }); - - // Calculate sponsor match score using fuzzy search - let sponsorMatchScore = 0; - let sponsorMatchNames: string | undefined; - - if (job.employer) { - const sponsorResults = visaSponsors.searchSponsors(job.employer, { - limit: 10, - minScore: 50, - }); - - const summary = - visaSponsors.calculateSponsorMatchSummary(sponsorResults); - sponsorMatchScore = summary.sponsorMatchScore; - sponsorMatchNames = summary.sponsorMatchNames ?? undefined; - } - - // Update score and sponsor match in database - await jobsRepo.updateJob(job.id, { - suitabilityScore: score, - suitabilityReason: reason, - sponsorMatchScore, - sponsorMatchNames, - }); - } - - progressHelpers.scoringComplete(scoredJobs.length); - console.log(`\n📊 Scored ${scoredJobs.length} jobs.`); - - // Step 5: Auto-process top jobs console.log("\n🏭 Auto-processing top jobs..."); - - const jobsToProcess = scoredJobs - .filter( - (j) => (j.suitabilityScore ?? 0) >= mergedConfig.minSuitabilityScore, - ) - .sort((a, b) => (b.suitabilityScore ?? 0) - (a.suitabilityScore ?? 0)) - .slice(0, mergedConfig.topN); - console.log( ` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`, ); - let processedCount = 0; + const { processedCount } = await processJobsStep({ + jobsToProcess, + processJob, + }); - if (jobsToProcess.length > 0) { - updateProgress({ - step: "processing", - jobsProcessed: 0, - totalToProcess: jobsToProcess.length, - }); - - for (let i = 0; i < jobsToProcess.length; i++) { - const job = jobsToProcess[i]; - progressHelpers.processingJob(i + 1, jobsToProcess.length, job); - - // Process job (Generate Summary + PDF) - // We catch errors here to ensure one failure doesn't stop the whole batch - const result = await processJob(job.id, { force: false }); - - if (result.success) { - processedCount++; - } else { - console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`); - } - - progressHelpers.jobComplete(i + 1, jobsToProcess.length); - } - } - - // Update pipeline run as completed await pipelineRepo.updatePipelineRun(pipelineRun.id, { status: "completed", completedAt: new Date().toISOString(), @@ -429,7 +117,7 @@ export async function runPipeline( progressHelpers.complete(created, processedCount); - await notifyPipelineWebhook("pipeline.completed", { + await notifyPipelineWebhookStep("pipeline.completed", { pipelineRunId: pipelineRun.id, jobsDiscovered: created, jobsScored: unprocessedJobs.length, @@ -453,7 +141,7 @@ export async function runPipeline( progressHelpers.failed(message); - await notifyPipelineWebhook("pipeline.failed", { + await notifyPipelineWebhookStep("pipeline.failed", { pipelineRunId: pipelineRun.id, error: message, }); diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts new file mode 100644 index 0000000..914b02d --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts @@ -0,0 +1,99 @@ +import type { PipelineConfig } from "@shared/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { discoverJobsStep } from "./discover-jobs"; + +vi.mock("../../repositories/jobs", () => ({ + getAllJobUrls: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../repositories/settings", () => ({ + getAllSettings: vi.fn(), +})); + +vi.mock("../../services/jobspy", () => ({ + runJobSpy: vi.fn(), +})); + +vi.mock("../../services/crawler", () => ({ + runCrawler: vi.fn(), +})); + +vi.mock("../../services/ukvisajobs", () => ({ + runUkVisaJobs: vi.fn(), +})); + +const config: PipelineConfig = { + topN: 10, + minSuitabilityScore: 50, + sources: ["indeed", "linkedin", "ukvisajobs"], + outputDir: "./tmp", + enableCrawling: true, + enableScoring: true, + enableImporting: true, + enableAutoTailoring: true, +}; + +describe("discoverJobsStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies jobspySites setting and aggregates source errors", async () => { + const settingsRepo = await import("../../repositories/settings"); + const jobSpy = await import("../../services/jobspy"); + const ukVisa = await import("../../services/ukvisajobs"); + + vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({ + searchTerms: JSON.stringify(["engineer"]), + jobspySites: JSON.stringify(["linkedin"]), + } as any); + + vi.mocked(jobSpy.runJobSpy).mockResolvedValue({ + success: true, + jobs: [ + { + source: "linkedin", + title: "Engineer", + employer: "ACME", + jobUrl: "https://example.com/job", + }, + ], + } as any); + + vi.mocked(ukVisa.runUkVisaJobs).mockResolvedValue({ + success: false, + error: "login failed", + } as any); + + const result = await discoverJobsStep({ mergedConfig: config }); + + expect(result.discoveredJobs).toHaveLength(1); + expect(result.sourceErrors).toEqual(["ukvisajobs: login failed"]); + expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith( + expect.objectContaining({ sites: ["linkedin"] }), + ); + }); + + it("throws when all enabled sources fail", async () => { + const settingsRepo = await import("../../repositories/settings"); + const ukVisa = await import("../../services/ukvisajobs"); + + vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({ + searchTerms: JSON.stringify(["engineer"]), + } as any); + + vi.mocked(ukVisa.runUkVisaJobs).mockResolvedValue({ + success: false, + error: "boom", + } as any); + + await expect( + discoverJobsStep({ + mergedConfig: { + ...config, + sources: ["ukvisajobs"], + }, + }), + ).rejects.toThrow("All sources failed: ukvisajobs: boom"); + }); +}); diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts new file mode 100644 index 0000000..2b6dfd6 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts @@ -0,0 +1,158 @@ +import type { CreateJobInput, PipelineConfig } from "@shared/types"; +import * as jobsRepo from "../../repositories/jobs"; +import * as settingsRepo from "../../repositories/settings"; +import { runCrawler } from "../../services/crawler"; +import { runJobSpy } from "../../services/jobspy"; +import { runUkVisaJobs } from "../../services/ukvisajobs"; +import { progressHelpers, updateProgress } from "../progress"; + +export async function discoverJobsStep(args: { + mergedConfig: PipelineConfig; +}): Promise<{ + discoveredJobs: CreateJobInput[]; + sourceErrors: string[]; +}> { + console.log("\n🕷️ Running crawler..."); + progressHelpers.startCrawling(); + + const discoveredJobs: CreateJobInput[] = []; + const sourceErrors: string[] = []; + + const settings = await settingsRepo.getAllSettings(); + + const searchTermsSetting = settings.searchTerms; + let searchTerms: string[] = []; + + if (searchTermsSetting) { + searchTerms = JSON.parse(searchTermsSetting) as string[]; + } else { + const defaultSearchTermsEnv = + process.env.JOBSPY_SEARCH_TERMS || "web developer"; + searchTerms = defaultSearchTermsEnv + .split("|") + .map((term) => term.trim()) + .filter(Boolean); + } + + let jobSpySites = args.mergedConfig.sources.filter( + (source): source is "indeed" | "linkedin" => + source === "indeed" || source === "linkedin", + ); + + const jobspySitesSettingRaw = settings.jobspySites; + if (jobspySitesSettingRaw) { + try { + const allowed = JSON.parse(jobspySitesSettingRaw); + if (Array.isArray(allowed)) { + jobSpySites = jobSpySites.filter((site) => allowed.includes(site)); + } + } catch { + // ignore JSON parse error + } + } + + if (jobSpySites.length > 0) { + updateProgress({ + step: "crawling", + detail: `JobSpy: scraping ${jobSpySites.join(", ")}...`, + }); + + const jobSpyResult = await runJobSpy({ + sites: jobSpySites, + searchTerms, + location: settings.jobspyLocation ?? undefined, + resultsWanted: settings.jobspyResultsWanted + ? parseInt(settings.jobspyResultsWanted, 10) + : undefined, + hoursOld: settings.jobspyHoursOld + ? parseInt(settings.jobspyHoursOld, 10) + : undefined, + countryIndeed: settings.jobspyCountryIndeed ?? undefined, + linkedinFetchDescription: + settings.jobspyLinkedinFetchDescription !== null && + settings.jobspyLinkedinFetchDescription !== undefined + ? settings.jobspyLinkedinFetchDescription === "1" + : undefined, + isRemote: + settings.jobspyIsRemote !== null && + settings.jobspyIsRemote !== undefined + ? settings.jobspyIsRemote === "1" + : undefined, + }); + + if (!jobSpyResult.success) { + sourceErrors.push(`jobspy: ${jobSpyResult.error ?? "unknown error"}`); + } else { + discoveredJobs.push(...jobSpyResult.jobs); + } + } + + if (args.mergedConfig.sources.includes("gradcracker")) { + updateProgress({ step: "crawling", detail: "Gradcracker: scraping..." }); + + const existingJobUrls = await jobsRepo.getAllJobUrls(); + const gradcrackerMaxJobs = settings.gradcrackerMaxJobsPerTerm + ? parseInt(settings.gradcrackerMaxJobsPerTerm, 10) + : 50; + + const crawlerResult = await runCrawler({ + existingJobUrls, + searchTerms, + maxJobsPerTerm: gradcrackerMaxJobs, + onProgress: (progress) => { + if (progress.listPagesTotal && progress.listPagesTotal > 0) { + const percent = Math.round( + ((progress.listPagesProcessed ?? 0) / progress.listPagesTotal) * + 100, + ); + updateProgress({ + step: "crawling", + detail: `Gradcracker: ${percent}% (scan ${progress.listPagesProcessed}/${progress.listPagesTotal}, found ${progress.jobCardsFound})`, + }); + } + }, + }); + + if (!crawlerResult.success) { + sourceErrors.push( + `gradcracker: ${crawlerResult.error ?? "unknown error"}`, + ); + } else { + discoveredJobs.push(...crawlerResult.jobs); + } + } + + if (args.mergedConfig.sources.includes("ukvisajobs")) { + updateProgress({ + step: "crawling", + detail: "UKVisaJobs: scraping visa-sponsoring jobs...", + }); + + const ukvisajobsMaxJobs = settings.ukvisajobsMaxJobs + ? parseInt(settings.ukvisajobsMaxJobs, 10) + : 50; + + const ukVisaResult = await runUkVisaJobs({ + maxJobs: ukvisajobsMaxJobs, + searchTerms, + }); + + if (!ukVisaResult.success) { + sourceErrors.push(`ukvisajobs: ${ukVisaResult.error ?? "unknown error"}`); + } else { + discoveredJobs.push(...ukVisaResult.jobs); + } + } + + if (discoveredJobs.length === 0 && sourceErrors.length > 0) { + throw new Error(`All sources failed: ${sourceErrors.join("; ")}`); + } + + if (sourceErrors.length > 0) { + console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`); + } + + progressHelpers.crawlingComplete(discoveredJobs.length); + + return { discoveredJobs, sourceErrors }; +} diff --git a/orchestrator/src/server/pipeline/steps/import-jobs.ts b/orchestrator/src/server/pipeline/steps/import-jobs.ts new file mode 100644 index 0000000..db11395 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/import-jobs.ts @@ -0,0 +1,17 @@ +import type { CreateJobInput } from "@shared/types"; +import * as jobsRepo from "../../repositories/jobs"; +import { progressHelpers } from "../progress"; + +export async function importJobsStep(args: { + discoveredJobs: CreateJobInput[]; +}): Promise<{ created: number; skipped: number }> { + console.log("\n💾 Importing jobs to database..."); + const { created, skipped } = await jobsRepo.bulkCreateJobs( + args.discoveredJobs, + ); + console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`); + + progressHelpers.importComplete(created, skipped); + + return { created, skipped }; +} diff --git a/orchestrator/src/server/pipeline/steps/index.ts b/orchestrator/src/server/pipeline/steps/index.ts new file mode 100644 index 0000000..99264c8 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/index.ts @@ -0,0 +1,8 @@ +export { discoverJobsStep } from "./discover-jobs"; +export { importJobsStep } from "./import-jobs"; +export { loadProfileStep } from "./load-profile"; +export { notifyPipelineWebhookStep } from "./notify-webhook"; +export { processJobsStep } from "./process-jobs"; +export { scoreJobsStep } from "./score-jobs"; +export { selectJobsStep } from "./select-jobs"; +export type { RunPipelineContext, ScoredJob } from "./types"; diff --git a/orchestrator/src/server/pipeline/steps/load-profile.ts b/orchestrator/src/server/pipeline/steps/load-profile.ts new file mode 100644 index 0000000..4467c68 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/load-profile.ts @@ -0,0 +1,12 @@ +import { getProfile } from "../../services/profile"; + +export async function loadProfileStep(): Promise> { + console.log("\n📋 Loading profile..."); + return getProfile().catch((error) => { + console.warn( + "⚠️ Failed to load profile for scoring, using empty profile:", + error, + ); + return {} as Record; + }); +} diff --git a/orchestrator/src/server/pipeline/steps/notify-webhook.ts b/orchestrator/src/server/pipeline/steps/notify-webhook.ts new file mode 100644 index 0000000..154d115 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/notify-webhook.ts @@ -0,0 +1,43 @@ +import * as settingsRepo from "../../repositories/settings"; + +export async function notifyPipelineWebhookStep( + event: "pipeline.completed" | "pipeline.failed", + payload: Record, +): Promise { + const overridePipelineWebhookUrl = + await settingsRepo.getSetting("pipelineWebhookUrl"); + const pipelineWebhookUrl = ( + overridePipelineWebhookUrl || + process.env.PIPELINE_WEBHOOK_URL || + process.env.WEBHOOK_URL || + "" + ).trim(); + + if (!pipelineWebhookUrl) return; + + try { + const headers: Record = { + "Content-Type": "application/json", + }; + const secret = process.env.WEBHOOK_SECRET; + if (secret) headers.Authorization = `Bearer ${secret}`; + + const response = await fetch(pipelineWebhookUrl, { + method: "POST", + headers, + body: JSON.stringify({ + event, + sentAt: new Date().toISOString(), + ...payload, + }), + }); + + if (!response.ok) { + console.warn( + `⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`, + ); + } + } catch (error) { + console.warn("⚠️ Pipeline webhook POST failed:", error); + } +} diff --git a/orchestrator/src/server/pipeline/steps/process-jobs.ts b/orchestrator/src/server/pipeline/steps/process-jobs.ts new file mode 100644 index 0000000..bd57929 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/process-jobs.ts @@ -0,0 +1,39 @@ +import { progressHelpers, updateProgress } from "../progress"; +import type { ScoredJob } from "./types"; + +type ProcessJobFn = ( + jobId: string, + options?: { force?: boolean }, +) => Promise<{ success: boolean; error?: string }>; + +export async function processJobsStep(args: { + jobsToProcess: ScoredJob[]; + processJob: ProcessJobFn; +}): Promise<{ processedCount: number }> { + let processedCount = 0; + + if (args.jobsToProcess.length > 0) { + updateProgress({ + step: "processing", + jobsProcessed: 0, + totalToProcess: args.jobsToProcess.length, + }); + + for (let i = 0; i < args.jobsToProcess.length; i++) { + const job = args.jobsToProcess[i]; + progressHelpers.processingJob(i + 1, args.jobsToProcess.length, job); + + const result = await args.processJob(job.id, { force: false }); + + if (result.success) { + processedCount++; + } else { + console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`); + } + + progressHelpers.jobComplete(i + 1, args.jobsToProcess.length); + } + } + + return { processedCount }; +} diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.ts b/orchestrator/src/server/pipeline/steps/score-jobs.ts new file mode 100644 index 0000000..306831c --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/score-jobs.ts @@ -0,0 +1,79 @@ +import type { Job } from "@shared/types"; +import * as jobsRepo from "../../repositories/jobs"; +import { scoreJobSuitability } from "../../services/scorer"; +import * as visaSponsors from "../../services/visa-sponsors/index"; +import { progressHelpers, updateProgress } from "../progress"; +import type { ScoredJob } from "./types"; + +export async function scoreJobsStep(args: { + profile: Record; +}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> { + console.log("\n🎯 Scoring jobs for suitability..."); + const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(); + + updateProgress({ + step: "scoring", + jobsDiscovered: unprocessedJobs.length, + jobsScored: 0, + jobsProcessed: 0, + totalToProcess: 0, + currentJob: undefined, + }); + + const scoredJobs: ScoredJob[] = []; + + for (let i = 0; i < unprocessedJobs.length; i++) { + const job = unprocessedJobs[i]; + const hasCachedScore = + typeof job.suitabilityScore === "number" && + !Number.isNaN(job.suitabilityScore); + + progressHelpers.scoringJob( + i + 1, + unprocessedJobs.length, + hasCachedScore ? `${job.title} (cached)` : job.title, + ); + + if (hasCachedScore) { + scoredJobs.push({ + ...job, + suitabilityScore: job.suitabilityScore as number, + suitabilityReason: job.suitabilityReason ?? "", + }); + continue; + } + + const { score, reason } = await scoreJobSuitability(job, args.profile); + scoredJobs.push({ + ...job, + suitabilityScore: score, + suitabilityReason: reason, + }); + + let sponsorMatchScore = 0; + let sponsorMatchNames: string | undefined; + + if (job.employer) { + const sponsorResults = visaSponsors.searchSponsors(job.employer, { + limit: 10, + minScore: 50, + }); + + const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults); + sponsorMatchScore = summary.sponsorMatchScore; + sponsorMatchNames = summary.sponsorMatchNames ?? undefined; + } + + await jobsRepo.updateJob(job.id, { + suitabilityScore: score, + suitabilityReason: reason, + sponsorMatchScore, + sponsorMatchNames, + }); + } + + progressHelpers.scoringComplete(scoredJobs.length); + console.log(`\n📊 Scored ${scoredJobs.length} jobs.`); + + return { unprocessedJobs, scoredJobs }; +} diff --git a/orchestrator/src/server/pipeline/steps/select-jobs.test.ts b/orchestrator/src/server/pipeline/steps/select-jobs.test.ts new file mode 100644 index 0000000..94b1741 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/select-jobs.test.ts @@ -0,0 +1,32 @@ +import type { PipelineConfig } from "@shared/types"; +import { describe, expect, it } from "vitest"; +import { selectJobsStep } from "./select-jobs"; + +const baseConfig: PipelineConfig = { + topN: 2, + minSuitabilityScore: 50, + sources: ["gradcracker"], + outputDir: "./tmp", + enableCrawling: true, + enableScoring: true, + enableImporting: true, + enableAutoTailoring: true, +}; + +describe("selectJobsStep", () => { + it("filters by min score, sorts descending, and limits topN", () => { + const jobs = [ + { id: "a", suitabilityScore: 90, suitabilityReason: "high" }, + { id: "b", suitabilityScore: 45, suitabilityReason: "low" }, + { id: "c", suitabilityScore: 80, suitabilityReason: "med" }, + { id: "d", suitabilityScore: 70, suitabilityReason: "ok" }, + ] as any; + + const selected = selectJobsStep({ + scoredJobs: jobs, + mergedConfig: baseConfig, + }); + + expect(selected.map((job) => job.id)).toEqual(["a", "c"]); + }); +}); diff --git a/orchestrator/src/server/pipeline/steps/select-jobs.ts b/orchestrator/src/server/pipeline/steps/select-jobs.ts new file mode 100644 index 0000000..5b454cc --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/select-jobs.ts @@ -0,0 +1,18 @@ +import type { PipelineConfig } from "@shared/types"; +import type { ScoredJob } from "./types"; + +export function selectJobsStep(args: { + scoredJobs: ScoredJob[]; + mergedConfig: PipelineConfig; +}): ScoredJob[] { + return args.scoredJobs + .filter( + (job) => + (job.suitabilityScore ?? 0) >= args.mergedConfig.minSuitabilityScore, + ) + .sort( + (left, right) => + (right.suitabilityScore ?? 0) - (left.suitabilityScore ?? 0), + ) + .slice(0, args.mergedConfig.topN); +} diff --git a/orchestrator/src/server/pipeline/steps/types.ts b/orchestrator/src/server/pipeline/steps/types.ts new file mode 100644 index 0000000..f61c256 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/types.ts @@ -0,0 +1,19 @@ +import type { CreateJobInput, Job, PipelineConfig } from "@shared/types"; + +export type ScoredJob = Job & { + suitabilityScore: number; + suitabilityReason: string; +}; + +export type RunPipelineContext = { + mergedConfig: PipelineConfig; + profile: Record; + discoveredJobs: CreateJobInput[]; + sourceErrors: string[]; + created: number; + skipped: number; + unprocessedJobs: Job[]; + scoredJobs: ScoredJob[]; + jobsToProcess: ScoredJob[]; + processedCount: number; +}; diff --git a/orchestrator/src/server/services/llm-service.ts b/orchestrator/src/server/services/llm-service.ts index 850cc2e..8b5f6a4 100644 --- a/orchestrator/src/server/services/llm-service.ts +++ b/orchestrator/src/server/services/llm-service.ts @@ -1,838 +1,14 @@ /** - * LLM service with provider-specific strategies and strict-first fallback. + * Compatibility facade for legacy imports. + * New implementation lives under ./llm/* */ -export type LlmProvider = - | "openrouter" - | "lmstudio" - | "ollama" - | "openai" - | "gemini"; - -type ResponseMode = "json_schema" | "json_object" | "text" | "none"; - -export interface JsonSchemaDefinition { - name: string; - schema: { - type: "object"; - properties: Record; - required: string[]; - additionalProperties: boolean; - }; -} - -export interface LlmRequestOptions<_T> { - /** The model to use (e.g., 'google/gemini-3-flash-preview') */ - model: string; - /** The prompt messages to send */ - messages: Array<{ role: "user" | "system" | "assistant"; content: string }>; - /** JSON schema for structured output */ - jsonSchema: JsonSchemaDefinition; - /** Number of retries on parsing failures (default: 0) */ - maxRetries?: number; - /** Delay between retries in ms (default: 500) */ - retryDelayMs?: number; - /** Job ID for logging purposes */ - jobId?: string; -} - -export interface LlmResult { - success: true; - data: T; -} - -export interface LlmError { - success: false; - error: string; -} - -export type LlmResponse = LlmResult | LlmError; - -export type LlmValidationResult = { - valid: boolean; - message: string | null; -}; - -type LlmServiceOptions = { - provider?: string | null; - baseUrl?: string | null; - apiKey?: string | null; -}; - -type ProviderStrategy = { - provider: LlmProvider; - defaultBaseUrl: string; - requiresApiKey: boolean; - modes: ResponseMode[]; - validationPaths: string[]; - buildRequest: (args: { - mode: ResponseMode; - baseUrl: string; - apiKey: string | null; - model: string; - messages: LlmRequestOptions["messages"]; - jsonSchema: JsonSchemaDefinition; - }) => { url: string; headers: Record; body: unknown }; - extractText: (response: unknown) => string | null; - isCapabilityError: (args: { - mode: ResponseMode; - status?: number; - body?: string; - }) => boolean; - getValidationUrls: (args: { - baseUrl: string; - apiKey: string | null; - }) => string[]; -}; - -interface LlmApiError extends Error { - status?: number; - body?: string; -} - -const modeCache = new Map(); - -const openRouterStrategy: ProviderStrategy = { - provider: "openrouter", - defaultBaseUrl: "https://openrouter.ai", - requiresApiKey: true, - modes: ["json_schema", "none"], - validationPaths: ["/api/v1/key"], - buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { - const body: Record = { - model, - messages, - stream: false, - plugins: [{ id: "response-healing" }], - }; - - if (mode === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: jsonSchema.name, - strict: true, - schema: jsonSchema.schema, - }, - }; - } - - return { - url: joinUrl(baseUrl, "/api/v1/chat/completions"), - headers: buildHeaders({ apiKey, provider: "openrouter" }), - body, - }; - }, - extractText: (response) => { - const content = getNestedValue(response, [ - "choices", - 0, - "message", - "content", - ]); - return typeof content === "string" ? content : null; - }, - isCapabilityError: ({ mode, status, body }) => - isCapabilityError({ mode, status, body }), - getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/api/v1/key")], -}; - -const lmStudioStrategy: ProviderStrategy = { - provider: "lmstudio", - defaultBaseUrl: "http://localhost:1234", - requiresApiKey: false, - modes: ["json_schema", "text", "none"], - validationPaths: ["/v1/models"], - buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => { - const body: Record = { - model, - messages, - stream: false, - }; - - if (mode === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: jsonSchema.name, - strict: true, - schema: jsonSchema.schema, - }, - }; - } else if (mode === "text") { - body.response_format = { type: "text" }; - } - - return { - url: joinUrl(baseUrl, "/v1/chat/completions"), - headers: buildHeaders({ apiKey: null, provider: "lmstudio" }), - body, - }; - }, - extractText: (response) => { - const content = getNestedValue(response, [ - "choices", - 0, - "message", - "content", - ]); - return typeof content === "string" ? content : null; - }, - isCapabilityError: ({ mode, status, body }) => - isCapabilityError({ mode, status, body }), - getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/v1/models")], -}; - -const ollamaStrategy: ProviderStrategy = { - provider: "ollama", - defaultBaseUrl: "http://localhost:11434", - requiresApiKey: false, - modes: ["json_schema", "text", "none"], - validationPaths: ["/v1/models", "/api/tags"], - buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => { - const body: Record = { - model, - messages, - stream: false, - }; - - if (mode === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: jsonSchema.name, - strict: true, - schema: jsonSchema.schema, - }, - }; - } else if (mode === "text") { - body.response_format = { type: "text" }; - } - - return { - url: joinUrl(baseUrl, "/v1/chat/completions"), - headers: buildHeaders({ apiKey: null, provider: "ollama" }), - body, - }; - }, - extractText: (response) => { - const content = getNestedValue(response, [ - "choices", - 0, - "message", - "content", - ]); - return typeof content === "string" ? content : null; - }, - isCapabilityError: ({ mode, status, body }) => - isCapabilityError({ mode, status, body }), - getValidationUrls: ({ baseUrl }) => [ - joinUrl(baseUrl, "/v1/models"), - joinUrl(baseUrl, "/api/tags"), - ], -}; - -const openAiStrategy: ProviderStrategy = { - provider: "openai", - defaultBaseUrl: "https://api.openai.com", - requiresApiKey: true, - modes: ["json_schema", "json_object", "none"], - validationPaths: ["/v1/models"], - buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { - const input = ensureJsonInstructionIfNeeded(messages, mode); - const body: Record = { - model, - input, - }; - - if (mode === "json_schema") { - body.text = { - format: { - type: "json_schema", - name: jsonSchema.name, - strict: true, - schema: jsonSchema.schema, - }, - }; - } else if (mode === "json_object") { - body.text = { format: { type: "json_object" } }; - } - - return { - url: joinUrl(baseUrl, "/v1/responses"), - headers: buildHeaders({ apiKey, provider: "openai" }), - body, - }; - }, - extractText: (response) => { - const direct = getNestedValue(response, ["output_text"]); - if (typeof direct === "string" && direct.trim()) return direct; - - const output = getNestedValue(response, ["output"]); - if (!Array.isArray(output)) return null; - - for (const item of output) { - const content = getNestedValue(item, ["content"]); - if (!Array.isArray(content)) continue; - for (const part of content) { - const type = getNestedValue(part, ["type"]); - const text = getNestedValue(part, ["text"]); - if (type === "output_text" && typeof text === "string") { - return text; - } - } - } - return null; - }, - isCapabilityError: ({ mode, status, body }) => - isCapabilityError({ mode, status, body }), - getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/v1/models")], -}; - -const geminiStrategy: ProviderStrategy = { - provider: "gemini", - defaultBaseUrl: "https://generativelanguage.googleapis.com", - requiresApiKey: true, - modes: ["json_schema", "json_object", "none"], - validationPaths: ["/v1beta/models"], - buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { - const { systemInstruction, contents } = toGeminiContents(messages); - const body: Record = { - contents, - }; - - if (systemInstruction) { - body.systemInstruction = systemInstruction; - } - - if (mode === "json_schema") { - body.generationConfig = { - responseMimeType: "application/json", - responseSchema: jsonSchema.schema, - }; - } else if (mode === "json_object") { - body.generationConfig = { - responseMimeType: "application/json", - }; - } - - const url = joinUrl( - baseUrl, - `/v1beta/models/${encodeURIComponent(model)}:generateContent`, - ); - const urlWithKey = addQueryParam(url, "key", apiKey ?? ""); - - return { - url: urlWithKey, - headers: buildHeaders({ apiKey: null, provider: "gemini" }), - body, - }; - }, - extractText: (response) => { - const parts = getNestedValue(response, [ - "candidates", - 0, - "content", - "parts", - ]); - if (!Array.isArray(parts)) return null; - const text = parts - .map((part) => getNestedValue(part, ["text"])) - .filter((part) => typeof part === "string") - .join(""); - return text || null; - }, - isCapabilityError: ({ mode, status, body }) => - isCapabilityError({ mode, status, body }), - getValidationUrls: ({ baseUrl, apiKey }) => { - const url = joinUrl(baseUrl, "/v1beta/models"); - return [addQueryParam(url, "key", apiKey ?? "")]; - }, -}; - -const strategies: Record = { - openrouter: openRouterStrategy, - lmstudio: lmStudioStrategy, - ollama: ollamaStrategy, - openai: openAiStrategy, - gemini: geminiStrategy, -}; - -export class LlmService { - private readonly provider: LlmProvider; - private readonly baseUrl: string; - private readonly apiKey: string | null; - private readonly strategy: ProviderStrategy; - - constructor(options: LlmServiceOptions = {}) { - const normalizedBaseUrl = - normalizeEnvInput(options.baseUrl) || - normalizeEnvInput(process.env.LLM_BASE_URL) || - null; - const resolvedProvider = normalizeProvider( - options.provider ?? process.env.LLM_PROVIDER ?? null, - normalizedBaseUrl, - ); - - const strategy = strategies[resolvedProvider]; - const baseUrl = normalizedBaseUrl || strategy.defaultBaseUrl; - - let apiKey = - normalizeEnvInput(options.apiKey) || - normalizeEnvInput(process.env.LLM_API_KEY) || - null; - - // Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY. - // This prevents users from losing access when upgrading (keys are often only shown once). - if ( - !apiKey && - resolvedProvider === "openrouter" && - normalizeEnvInput(process.env.OPENROUTER_API_KEY) - ) { - console.warn( - "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.", - ); - const migrated = normalizeEnvInput(process.env.OPENROUTER_API_KEY); - if (migrated) { - process.env.LLM_API_KEY = migrated; - apiKey = migrated; - } - } - - this.provider = resolvedProvider; - this.baseUrl = baseUrl; - this.apiKey = apiKey; - this.strategy = strategy; - } - - async callJson(options: LlmRequestOptions): Promise> { - if (this.strategy.requiresApiKey && !this.apiKey) { - return { success: false, error: "LLM API key not configured" }; - } - - const { - model, - messages, - jsonSchema, - maxRetries = 0, - retryDelayMs = 500, - } = options; - const jobId = options.jobId; - - const cacheKey = `${this.provider}:${this.baseUrl}`; - const cachedMode = modeCache.get(cacheKey); - const modes = cachedMode - ? [cachedMode, ...this.strategy.modes.filter((m) => m !== cachedMode)] - : this.strategy.modes; - - for (const mode of modes) { - const result = await this.tryMode({ - mode, - model, - messages, - jsonSchema, - maxRetries, - retryDelayMs, - jobId, - }); - - if (result.success) { - modeCache.set(cacheKey, mode); - return result; - } - - if (!result.success && result.error.startsWith("CAPABILITY:")) { - continue; - } - - return result; - } - - return { success: false, error: "All provider modes failed" }; - } - - getProvider(): LlmProvider { - return this.provider; - } - - getBaseUrl(): string { - return this.baseUrl; - } - - async validateCredentials(): Promise { - if (this.strategy.requiresApiKey && !this.apiKey) { - return { valid: false, message: "LLM API key is missing." }; - } - - const urls = this.strategy.getValidationUrls({ - baseUrl: this.baseUrl, - apiKey: this.apiKey, - }); - let lastMessage: string | null = null; - - for (const url of urls) { - try { - const response = await fetch(url, { - method: "GET", - headers: buildHeaders({ - apiKey: this.apiKey, - provider: this.provider, - }), - }); - - if (response.ok) { - return { valid: true, message: null }; - } - - const detail = await getResponseDetail(response); - if (response.status === 401) { - return { - valid: false, - message: "Invalid LLM API key. Check the key and try again.", - }; - } - - lastMessage = detail || `LLM provider returned ${response.status}`; - } catch (error) { - lastMessage = - error instanceof Error ? error.message : "LLM validation failed."; - } - } - - return { - valid: false, - message: lastMessage || "LLM provider validation failed.", - }; - } - - private async tryMode(args: { - mode: ResponseMode; - model: string; - messages: LlmRequestOptions["messages"]; - jsonSchema: JsonSchemaDefinition; - maxRetries: number; - retryDelayMs: number; - jobId?: string; - }): Promise> { - const { mode, model, messages, jsonSchema, maxRetries, retryDelayMs } = - args; - const jobId = args.jobId; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - if (attempt > 0) { - console.log( - `🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`, - ); - await sleep(retryDelayMs * attempt); - } - - const { url, headers, body } = this.strategy.buildRequest({ - mode, - baseUrl: this.baseUrl, - apiKey: this.apiKey, - model, - messages, - jsonSchema, - }); - - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.text().catch(() => "No error body"); - const parsedError = parseErrorMessage(errorBody); - const detail = parsedError ? ` - ${truncate(parsedError, 400)}` : ""; - const err = new Error( - `LLM API error: ${response.status}${detail}`, - ) as LlmApiError; - err.status = response.status; - err.body = errorBody; - throw err; - } - - const data = await response.json(); - const content = this.strategy.extractText(data); - - if (!content) { - throw new Error("No content in response"); - } - - const parsed = parseJsonContent(content, jobId); - return { success: true, data: parsed }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const status = (error as LlmApiError).status; - const body = (error as LlmApiError).body; - - if ( - this.strategy.isCapabilityError({ - mode, - status, - body, - }) - ) { - return { success: false, error: `CAPABILITY:${message}` }; - } - - const shouldRetry = - message.includes("parse") || - status === 429 || - (status !== undefined && status >= 500 && status <= 599) || - message.toLowerCase().includes("timeout") || - message.toLowerCase().includes("fetch failed"); - - if (attempt < maxRetries && shouldRetry) { - console.warn( - `⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`, - ); - continue; - } - - return { success: false, error: message }; - } - } - - return { success: false, error: "All retry attempts failed" }; - } -} - -export function parseJsonContent(content: string, jobId?: string): T { - let candidate = content.trim(); - - candidate = candidate - .replace(/```(?:json|JSON)?\s*/g, "") - .replace(/```/g, "") - .trim(); - - const firstBrace = candidate.indexOf("{"); - const lastBrace = candidate.lastIndexOf("}"); - - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - candidate = candidate.substring(firstBrace, lastBrace + 1); - } - - try { - return JSON.parse(candidate) as T; - } catch (error) { - console.error( - `❌ [${jobId ?? "unknown"}] Failed to parse JSON:`, - candidate.substring(0, 200), - ); - throw new Error( - `Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`, - ); - } -} - -function normalizeProvider( - raw: string | null, - baseUrl: string | null, -): LlmProvider { - const normalized = raw?.trim().toLowerCase(); - if (normalized === "openai_compatible") { - if ( - baseUrl?.includes("localhost:1234") || - baseUrl?.includes("127.0.0.1:1234") - ) { - return "lmstudio"; - } - return "openai"; - } - if (normalized === "openai") return "openai"; - if (normalized === "gemini") return "gemini"; - if (normalized === "lmstudio") return "lmstudio"; - if (normalized === "ollama") return "ollama"; - if (normalized && normalized !== "openrouter") { - console.warn( - `⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`, - ); - } - return "openrouter"; -} - -function normalizeEnvInput(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - return trimmed ? trimmed : null; -} - -function buildHeaders(args: { - apiKey: string | null; - provider: LlmProvider; -}): Record { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (args.apiKey) { - headers.Authorization = `Bearer ${args.apiKey}`; - } - - if (args.provider === "openrouter") { - headers["HTTP-Referer"] = "JobOps"; - headers["X-Title"] = "JobOpsOrchestrator"; - } - - return headers; -} - -function ensureJsonInstructionIfNeeded( - messages: LlmRequestOptions["messages"], - mode: ResponseMode, -) { - if (mode !== "json_object") return messages; - const hasJson = messages.some((message) => - message.content.toLowerCase().includes("json"), - ); - if (hasJson) return messages; - return [ - { - role: "system" as const, - content: "Respond with valid JSON.", - }, - ...messages, - ]; -} - -function toGeminiContents(messages: LlmRequestOptions["messages"]): { - systemInstruction: { parts: Array<{ text: string }> } | null; - contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>; -} { - const systemParts: string[] = []; - const contents = messages - .filter((message) => { - if (message.role === "system") { - systemParts.push(message.content); - return false; - } - return true; - }) - .map((message) => { - const role: "user" | "model" = - message.role === "assistant" ? "model" : "user"; - return { role, parts: [{ text: message.content }] }; - }); - - const systemInstruction = systemParts.length - ? { parts: [{ text: systemParts.join("\n") }] } - : null; - - return { systemInstruction, contents }; -} - -async function getResponseDetail(response: Response): Promise { - try { - const payload = await response.json(); - if (payload && typeof payload === "object" && "error" in payload) { - const errorObj = payload.error as { - message?: string; - code?: number | string; - }; - const message = errorObj?.message || ""; - const code = errorObj?.code ? ` (${errorObj.code})` : ""; - return `${message}${code}`.trim(); - } - } catch { - // ignore JSON parse errors - } - - return response.text().catch(() => ""); -} - -function isCapabilityError(args: { - mode: ResponseMode; - status?: number; - body?: string; -}): boolean { - if (args.mode === "none") return false; - if (args.status !== 400) return false; - const body = (args.body || "").toLowerCase(); - - if (body.includes("model") && body.includes("not")) return false; - if (body.includes("unknown model")) return false; - - return ( - body.includes("response_format") || - body.includes("json_schema") || - body.includes("json_object") || - body.includes("text.format") || - body.includes("response schema") || - body.includes("responseschema") || - body.includes("responsemime") || - body.includes("response_mime") - ); -} - -function joinUrl(baseUrl: string, path: string): string { - const base = baseUrl.replace(/\/+$/, ""); - const suffix = path.startsWith("/") ? path : `/${path}`; - return `${base}${suffix}`; -} - -function addQueryParam(url: string, key: string, value: string): string { - const connector = url.includes("?") ? "&" : "?"; - return `${url}${connector}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; -} - -type PathSegment = string | number; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function getNestedValue(value: unknown, path: PathSegment[]): unknown { - let current: unknown = value; - for (const segment of path) { - if (typeof segment === "number") { - if (!Array.isArray(current)) return undefined; - current = current[segment]; - continue; - } - if (!isRecord(current)) return undefined; - current = current[segment]; - } - return current; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 1)}…`; -} - -function parseErrorMessage(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) return ""; - - try { - const payload = JSON.parse(trimmed) as unknown; - const candidates: Array = [ - getNestedValue(payload, ["error", "message"]), - getNestedValue(payload, ["error", "error", "message"]), - getNestedValue(payload, ["error"]), - getNestedValue(payload, ["message"]), - getNestedValue(payload, ["detail"]), - getNestedValue(payload, ["msg"]), - ]; - - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - } - - if (typeof payload === "string" && payload.trim()) { - return payload.trim(); - } - } catch { - // Not JSON - } - - return trimmed; -} +export { LlmService } from "./llm/service"; +export type { + JsonSchemaDefinition, + LlmProvider, + LlmRequestOptions, + LlmResponse, + LlmValidationResult, +} from "./llm/types"; +export { parseJsonContent } from "./llm/utils/json"; diff --git a/orchestrator/src/server/services/llm/policies/capability-fallback.test.ts b/orchestrator/src/server/services/llm/policies/capability-fallback.test.ts new file mode 100644 index 0000000..d194400 --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/capability-fallback.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isCapabilityError } from "./capability-fallback"; + +describe("capability fallback policy", () => { + it("detects structured output capability errors", () => { + expect( + isCapabilityError({ + mode: "json_schema", + status: 400, + body: "response_format json_schema is unsupported", + }), + ).toBe(true); + }); + + it("excludes model-not-found errors", () => { + expect( + isCapabilityError({ + mode: "json_schema", + status: 400, + body: "unknown model: test-model", + }), + ).toBe(false); + }); + + it("never treats none mode as capability fallback", () => { + expect( + isCapabilityError({ + mode: "none", + status: 400, + body: "response_format json_schema is unsupported", + }), + ).toBe(false); + }); +}); diff --git a/orchestrator/src/server/services/llm/policies/capability-fallback.ts b/orchestrator/src/server/services/llm/policies/capability-fallback.ts new file mode 100644 index 0000000..54d9d4f --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/capability-fallback.ts @@ -0,0 +1,25 @@ +import type { ResponseMode } from "../types"; + +export function isCapabilityError(args: { + mode: ResponseMode; + status?: number; + body?: string; +}): boolean { + if (args.mode === "none") return false; + if (args.status !== 400) return false; + const body = (args.body || "").toLowerCase(); + + if (body.includes("model") && body.includes("not")) return false; + if (body.includes("unknown model")) return false; + + return ( + body.includes("response_format") || + body.includes("json_schema") || + body.includes("json_object") || + body.includes("text.format") || + body.includes("response schema") || + body.includes("responseschema") || + body.includes("responsemime") || + body.includes("response_mime") + ); +} diff --git a/orchestrator/src/server/services/llm/policies/mode-selection.test.ts b/orchestrator/src/server/services/llm/policies/mode-selection.test.ts new file mode 100644 index 0000000..3629e60 --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/mode-selection.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + buildModeCacheKey, + clearModeCache, + getOrderedModes, + rememberSuccessfulMode, +} from "./mode-selection"; + +describe("mode-selection policy", () => { + it("returns provider modes in declared order when cache is empty", () => { + clearModeCache(); + const key = buildModeCacheKey("openrouter", "https://openrouter.ai"); + expect(getOrderedModes(key, ["json_schema", "none"])).toEqual([ + "json_schema", + "none", + ]); + }); + + it("prefers cached mode first", () => { + clearModeCache(); + const key = buildModeCacheKey("lmstudio", "http://localhost:1234"); + rememberSuccessfulMode(key, "text"); + + expect(getOrderedModes(key, ["json_schema", "text", "none"])).toEqual([ + "text", + "json_schema", + "none", + ]); + }); + + it("keeps cache scoped by provider+baseUrl", () => { + clearModeCache(); + const keyA = buildModeCacheKey("lmstudio", "http://localhost:1234"); + const keyB = buildModeCacheKey("lmstudio", "http://localhost:1235"); + rememberSuccessfulMode(keyA, "text"); + + expect(getOrderedModes(keyA, ["json_schema", "text", "none"])).toEqual([ + "text", + "json_schema", + "none", + ]); + expect(getOrderedModes(keyB, ["json_schema", "text", "none"])).toEqual([ + "json_schema", + "text", + "none", + ]); + }); +}); diff --git a/orchestrator/src/server/services/llm/policies/mode-selection.ts b/orchestrator/src/server/services/llm/policies/mode-selection.ts new file mode 100644 index 0000000..79b5b1d --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/mode-selection.ts @@ -0,0 +1,28 @@ +import type { ResponseMode } from "../types"; + +const modeCache = new Map(); + +export function buildModeCacheKey(provider: string, baseUrl: string): string { + return `${provider}:${baseUrl}`; +} + +export function getOrderedModes( + cacheKey: string, + modes: ResponseMode[], +): ResponseMode[] { + const cachedMode = modeCache.get(cacheKey); + return cachedMode + ? [cachedMode, ...modes.filter((mode) => mode !== cachedMode)] + : modes; +} + +export function rememberSuccessfulMode( + cacheKey: string, + mode: ResponseMode, +): void { + modeCache.set(cacheKey, mode); +} + +export function clearModeCache(): void { + modeCache.clear(); +} diff --git a/orchestrator/src/server/services/llm/policies/retry-policy.test.ts b/orchestrator/src/server/services/llm/policies/retry-policy.test.ts new file mode 100644 index 0000000..38771ba --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/retry-policy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { getRetryDelayMs, shouldRetryAttempt } from "./retry-policy"; + +describe("retry-policy", () => { + it("retries parse errors", () => { + expect( + shouldRetryAttempt({ message: "Failed to parse JSON", status: 200 }), + ).toBe(true); + }); + + it("retries on 429 and 5xx", () => { + expect(shouldRetryAttempt({ message: "rate limited", status: 429 })).toBe( + true, + ); + expect(shouldRetryAttempt({ message: "server error", status: 503 })).toBe( + true, + ); + }); + + it("retries timeout and fetch failures", () => { + expect( + shouldRetryAttempt({ message: "Request timeout occurred", status: 0 }), + ).toBe(true); + expect( + shouldRetryAttempt({ message: "TypeError: fetch failed", status: 0 }), + ).toBe(true); + }); + + it("does not retry non-retryable 4xx", () => { + expect( + shouldRetryAttempt({ + message: "LLM API error: 400 bad request", + status: 400, + }), + ).toBe(false); + }); + + it("preserves backoff multiplier behavior", () => { + expect(getRetryDelayMs(500, 1)).toBe(500); + expect(getRetryDelayMs(500, 2)).toBe(1000); + }); +}); diff --git a/orchestrator/src/server/services/llm/policies/retry-policy.ts b/orchestrator/src/server/services/llm/policies/retry-policy.ts new file mode 100644 index 0000000..b9eedc7 --- /dev/null +++ b/orchestrator/src/server/services/llm/policies/retry-policy.ts @@ -0,0 +1,16 @@ +export function shouldRetryAttempt(args: { + message: string; + status?: number; +}): boolean { + return ( + args.message.includes("parse") || + args.status === 429 || + (args.status !== undefined && args.status >= 500 && args.status <= 599) || + args.message.toLowerCase().includes("timeout") || + args.message.toLowerCase().includes("fetch failed") + ); +} + +export function getRetryDelayMs(baseDelayMs: number, attempt: number): number { + return baseDelayMs * attempt; +} diff --git a/orchestrator/src/server/services/llm/providers/factory.ts b/orchestrator/src/server/services/llm/providers/factory.ts new file mode 100644 index 0000000..a366ea3 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/factory.ts @@ -0,0 +1,72 @@ +import { isCapabilityError } from "../policies/capability-fallback"; +import type { + JsonSchemaDefinition, + LlmRequestOptions, + ProviderStrategy, + ResponseMode, +} from "../types"; +import { joinUrl } from "../utils/http"; +import { getNestedValue } from "../utils/object"; + +type ProviderStrategyArgs = Omit< + ProviderStrategy, + "isCapabilityError" | "getValidationUrls" +> & { + getValidationUrls?: ProviderStrategy["getValidationUrls"]; +}; + +export function createProviderStrategy( + args: ProviderStrategyArgs, +): ProviderStrategy { + return { + ...args, + isCapabilityError: ({ mode, status, body }) => + isCapabilityError({ mode, status, body }), + getValidationUrls: + args.getValidationUrls ?? + (({ baseUrl }) => + args.validationPaths.map((path) => joinUrl(baseUrl, path))), + }; +} + +export function buildChatCompletionsBody(args: { + mode: ResponseMode; + model: string; + messages: LlmRequestOptions["messages"]; + jsonSchema: JsonSchemaDefinition; + extra?: Record; +}): Record { + const body: Record = { + model: args.model, + messages: args.messages, + stream: false, + ...(args.extra ?? {}), + }; + + if (args.mode === "json_schema") { + body.response_format = { + type: "json_schema", + json_schema: { + name: args.jsonSchema.name, + strict: true, + schema: args.jsonSchema.schema, + }, + }; + } else if (args.mode === "json_object") { + body.response_format = { type: "json_object" }; + } else if (args.mode === "text") { + body.response_format = { type: "text" }; + } + + return body; +} + +export function extractChatCompletionsText(response: unknown): string | null { + const content = getNestedValue(response, [ + "choices", + 0, + "message", + "content", + ]); + return typeof content === "string" ? content : null; +} diff --git a/orchestrator/src/server/services/llm/providers/gemini.ts b/orchestrator/src/server/services/llm/providers/gemini.ts new file mode 100644 index 0000000..a56ebf1 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/gemini.ts @@ -0,0 +1,89 @@ +import type { LlmRequestOptions } from "../types"; +import { addQueryParam, buildHeaders, joinUrl } from "../utils/http"; +import { getNestedValue } from "../utils/object"; +import { createProviderStrategy } from "./factory"; + +export const geminiStrategy = createProviderStrategy({ + provider: "gemini", + defaultBaseUrl: "https://generativelanguage.googleapis.com", + requiresApiKey: true, + modes: ["json_schema", "json_object", "none"], + validationPaths: ["/v1beta/models"], + buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { + const { systemInstruction, contents } = toGeminiContents(messages); + const body: Record = { + contents, + }; + + if (systemInstruction) { + body.systemInstruction = systemInstruction; + } + + if (mode === "json_schema") { + body.generationConfig = { + responseMimeType: "application/json", + responseSchema: jsonSchema.schema, + }; + } else if (mode === "json_object") { + body.generationConfig = { + responseMimeType: "application/json", + }; + } + + const url = joinUrl( + baseUrl, + `/v1beta/models/${encodeURIComponent(model)}:generateContent`, + ); + const urlWithKey = addQueryParam(url, "key", apiKey ?? ""); + + return { + url: urlWithKey, + headers: buildHeaders({ apiKey: null, provider: "gemini" }), + body, + }; + }, + extractText: (response) => { + const parts = getNestedValue(response, [ + "candidates", + 0, + "content", + "parts", + ]); + if (!Array.isArray(parts)) return null; + const text = parts + .map((part) => getNestedValue(part, ["text"])) + .filter((part) => typeof part === "string") + .join(""); + return text || null; + }, + getValidationUrls: ({ baseUrl, apiKey }) => { + const url = joinUrl(baseUrl, "/v1beta/models"); + return [addQueryParam(url, "key", apiKey ?? "")]; + }, +}); + +function toGeminiContents(messages: LlmRequestOptions["messages"]): { + systemInstruction: { parts: Array<{ text: string }> } | null; + contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>; +} { + const systemParts: string[] = []; + const contents = messages + .filter((message) => { + if (message.role === "system") { + systemParts.push(message.content); + return false; + } + return true; + }) + .map((message) => { + const role: "user" | "model" = + message.role === "assistant" ? "model" : "user"; + return { role, parts: [{ text: message.content }] }; + }); + + const systemInstruction = systemParts.length + ? { parts: [{ text: systemParts.join("\n") }] } + : null; + + return { systemInstruction, contents }; +} diff --git a/orchestrator/src/server/services/llm/providers/index.ts b/orchestrator/src/server/services/llm/providers/index.ts new file mode 100644 index 0000000..034f176 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/index.ts @@ -0,0 +1,14 @@ +import type { LlmProvider, ProviderStrategy } from "../types"; +import { geminiStrategy } from "./gemini"; +import { lmStudioStrategy } from "./lmstudio"; +import { ollamaStrategy } from "./ollama"; +import { openAiStrategy } from "./openai"; +import { openRouterStrategy } from "./openrouter"; + +export const strategies: Record = { + openrouter: openRouterStrategy, + lmstudio: lmStudioStrategy, + ollama: ollamaStrategy, + openai: openAiStrategy, + gemini: geminiStrategy, +}; diff --git a/orchestrator/src/server/services/llm/providers/lmstudio.ts b/orchestrator/src/server/services/llm/providers/lmstudio.ts new file mode 100644 index 0000000..b8c1114 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/lmstudio.ts @@ -0,0 +1,22 @@ +import { buildHeaders, joinUrl } from "../utils/http"; +import { + buildChatCompletionsBody, + createProviderStrategy, + extractChatCompletionsText, +} from "./factory"; + +export const lmStudioStrategy = createProviderStrategy({ + provider: "lmstudio", + defaultBaseUrl: "http://localhost:1234", + requiresApiKey: false, + modes: ["json_schema", "text", "none"], + validationPaths: ["/v1/models"], + buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => { + return { + url: joinUrl(baseUrl, "/v1/chat/completions"), + headers: buildHeaders({ apiKey: null, provider: "lmstudio" }), + body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }), + }; + }, + extractText: extractChatCompletionsText, +}); diff --git a/orchestrator/src/server/services/llm/providers/ollama.ts b/orchestrator/src/server/services/llm/providers/ollama.ts new file mode 100644 index 0000000..6c803ba --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/ollama.ts @@ -0,0 +1,22 @@ +import { buildHeaders, joinUrl } from "../utils/http"; +import { + buildChatCompletionsBody, + createProviderStrategy, + extractChatCompletionsText, +} from "./factory"; + +export const ollamaStrategy = createProviderStrategy({ + provider: "ollama", + defaultBaseUrl: "http://localhost:11434", + requiresApiKey: false, + modes: ["json_schema", "text", "none"], + validationPaths: ["/v1/models", "/api/tags"], + buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => { + return { + url: joinUrl(baseUrl, "/v1/chat/completions"), + headers: buildHeaders({ apiKey: null, provider: "ollama" }), + body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }), + }; + }, + extractText: extractChatCompletionsText, +}); diff --git a/orchestrator/src/server/services/llm/providers/openai.ts b/orchestrator/src/server/services/llm/providers/openai.ts new file mode 100644 index 0000000..79ba3d8 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/openai.ts @@ -0,0 +1,76 @@ +import type { LlmRequestOptions, ResponseMode } from "../types"; +import { buildHeaders, joinUrl } from "../utils/http"; +import { getNestedValue } from "../utils/object"; +import { createProviderStrategy } from "./factory"; + +export const openAiStrategy = createProviderStrategy({ + provider: "openai", + defaultBaseUrl: "https://api.openai.com", + requiresApiKey: true, + modes: ["json_schema", "json_object", "none"], + validationPaths: ["/v1/models"], + buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { + const input = ensureJsonInstructionIfNeeded(messages, mode); + const body: Record = { + model, + input, + }; + + if (mode === "json_schema") { + body.text = { + format: { + type: "json_schema", + name: jsonSchema.name, + strict: true, + schema: jsonSchema.schema, + }, + }; + } else if (mode === "json_object") { + body.text = { format: { type: "json_object" } }; + } + + return { + url: joinUrl(baseUrl, "/v1/responses"), + headers: buildHeaders({ apiKey, provider: "openai" }), + body, + }; + }, + extractText: (response) => { + const direct = getNestedValue(response, ["output_text"]); + if (typeof direct === "string" && direct.trim()) return direct; + + const output = getNestedValue(response, ["output"]); + if (!Array.isArray(output)) return null; + + for (const item of output) { + const content = getNestedValue(item, ["content"]); + if (!Array.isArray(content)) continue; + for (const part of content) { + const type = getNestedValue(part, ["type"]); + const text = getNestedValue(part, ["text"]); + if (type === "output_text" && typeof text === "string") { + return text; + } + } + } + return null; + }, +}); + +function ensureJsonInstructionIfNeeded( + messages: LlmRequestOptions["messages"], + mode: ResponseMode, +) { + if (mode !== "json_object") return messages; + const hasJson = messages.some((message) => + message.content.toLowerCase().includes("json"), + ); + if (hasJson) return messages; + return [ + { + role: "system" as const, + content: "Respond with valid JSON.", + }, + ...messages, + ]; +} diff --git a/orchestrator/src/server/services/llm/providers/openrouter.ts b/orchestrator/src/server/services/llm/providers/openrouter.ts new file mode 100644 index 0000000..c77e95d --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/openrouter.ts @@ -0,0 +1,28 @@ +import { buildHeaders, joinUrl } from "../utils/http"; +import { + buildChatCompletionsBody, + createProviderStrategy, + extractChatCompletionsText, +} from "./factory"; + +export const openRouterStrategy = createProviderStrategy({ + provider: "openrouter", + defaultBaseUrl: "https://openrouter.ai", + requiresApiKey: true, + modes: ["json_schema", "none"], + validationPaths: ["/api/v1/key"], + buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => { + return { + url: joinUrl(baseUrl, "/api/v1/chat/completions"), + headers: buildHeaders({ apiKey, provider: "openrouter" }), + body: buildChatCompletionsBody({ + mode, + model, + messages, + jsonSchema, + extra: { plugins: [{ id: "response-healing" }] }, + }), + }; + }, + extractText: extractChatCompletionsText, +}); diff --git a/orchestrator/src/server/services/llm/providers/providers.test.ts b/orchestrator/src/server/services/llm/providers/providers.test.ts new file mode 100644 index 0000000..cd3cc05 --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/providers.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { geminiStrategy } from "./gemini"; +import { lmStudioStrategy } from "./lmstudio"; +import { ollamaStrategy } from "./ollama"; +import { openAiStrategy } from "./openai"; +import { openRouterStrategy } from "./openrouter"; + +const schema = { + name: "test_schema", + schema: { + type: "object" as const, + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, +}; + +const messages = [{ role: "user" as const, content: "hello" }]; + +describe("provider adapters", () => { + it("builds requests for each provider/mode path", () => { + const cases = [ + { + name: "openrouter-json_schema", + strategy: openRouterStrategy, + args: { + mode: "json_schema" as const, + baseUrl: "https://openrouter.ai", + apiKey: "x", + model: "model-a", + }, + expectedUrl: "https://openrouter.ai/api/v1/chat/completions", + expectedResponseFormat: "json_schema", + }, + { + name: "openai-json_object", + strategy: openAiStrategy, + args: { + mode: "json_object" as const, + baseUrl: "https://api.openai.com", + apiKey: "x", + model: "model-a", + }, + expectedUrl: "https://api.openai.com/v1/responses", + }, + { + name: "gemini-json_schema", + strategy: geminiStrategy, + args: { + mode: "json_schema" as const, + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "x", + model: "gemini-1.5-flash", + }, + expectedUrlContains: [":generateContent", "key=x"], + }, + { + name: "lmstudio-text", + strategy: lmStudioStrategy, + args: { + mode: "text" as const, + baseUrl: "http://localhost:1234", + apiKey: null, + model: "local", + }, + expectedUrl: "http://localhost:1234/v1/chat/completions", + expectedResponseFormat: "text", + }, + { + name: "ollama-none", + strategy: ollamaStrategy, + args: { + mode: "none" as const, + baseUrl: "http://localhost:11434", + apiKey: null, + model: "local", + }, + expectedUrl: "http://localhost:11434/v1/chat/completions", + }, + ]; + + for (const testCase of cases) { + const request = testCase.strategy.buildRequest({ + ...testCase.args, + messages, + jsonSchema: schema, + }); + + if (testCase.expectedUrl) { + expect(request.url, testCase.name).toBe(testCase.expectedUrl); + } + if (testCase.expectedUrlContains) { + for (const expectedPart of testCase.expectedUrlContains) { + expect(request.url, testCase.name).toContain(expectedPart); + } + } + + if (testCase.expectedResponseFormat) { + const body = request.body as Record; + expect( + (body.response_format as Record).type, + testCase.name, + ).toBe(testCase.expectedResponseFormat); + } + } + }); + + it("extracts text consistently for chat-completions providers", () => { + const response = { + choices: [{ message: { content: "ok" } }], + }; + expect(openRouterStrategy.extractText(response)).toBe("ok"); + expect(lmStudioStrategy.extractText(response)).toBe("ok"); + expect(ollamaStrategy.extractText(response)).toBe("ok"); + }); + + it("extracts text for openai and gemini variants", () => { + expect(openAiStrategy.extractText({ output_text: "openai-direct" })).toBe( + "openai-direct", + ); + expect( + openAiStrategy.extractText({ + output: [ + { + content: [{ type: "output_text", text: "openai-nested" }], + }, + ], + }), + ).toBe("openai-nested"); + + expect( + geminiStrategy.extractText({ + candidates: [{ content: { parts: [{ text: "gemini" }] } }], + }), + ).toBe("gemini"); + }); +}); diff --git a/orchestrator/src/server/services/llm/service.ts b/orchestrator/src/server/services/llm/service.ts new file mode 100644 index 0000000..c4c2c90 --- /dev/null +++ b/orchestrator/src/server/services/llm/service.ts @@ -0,0 +1,283 @@ +import { toStringOrNull } from "@shared/utils/type-conversion"; +import { + buildModeCacheKey, + getOrderedModes, + rememberSuccessfulMode, +} from "./policies/mode-selection"; +import { getRetryDelayMs, shouldRetryAttempt } from "./policies/retry-policy"; +import { strategies } from "./providers"; +import type { + JsonSchemaDefinition, + LlmApiError, + LlmProvider, + LlmRequestOptions, + LlmResponse, + LlmServiceOptions, + LlmValidationResult, + ResponseMode, +} from "./types"; +import { buildHeaders, getResponseDetail } from "./utils/http"; +import { parseJsonContent } from "./utils/json"; +import { parseErrorMessage, truncate } from "./utils/string"; + +export class LlmService { + private readonly provider: LlmProvider; + private readonly baseUrl: string; + private readonly apiKey: string | null; + private readonly strategy: (typeof strategies)[LlmProvider]; + + constructor(options: LlmServiceOptions = {}) { + const normalizedBaseUrl = + toStringOrNull(options.baseUrl) || + toStringOrNull(process.env.LLM_BASE_URL) || + null; + const resolvedProvider = normalizeProvider( + options.provider ?? process.env.LLM_PROVIDER ?? null, + normalizedBaseUrl, + ); + + const strategy = strategies[resolvedProvider]; + const baseUrl = normalizedBaseUrl || strategy.defaultBaseUrl; + + let apiKey = + toStringOrNull(options.apiKey) || + toStringOrNull(process.env.LLM_API_KEY) || + null; + + // Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY. + // This prevents users from losing access when upgrading (keys are often only shown once). + if ( + !apiKey && + resolvedProvider === "openrouter" && + toStringOrNull(process.env.OPENROUTER_API_KEY) + ) { + console.warn( + "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.", + ); + const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY); + if (migrated) { + process.env.LLM_API_KEY = migrated; + apiKey = migrated; + } + } + + this.provider = resolvedProvider; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.strategy = strategy; + } + + async callJson(options: LlmRequestOptions): Promise> { + if (this.strategy.requiresApiKey && !this.apiKey) { + return { success: false, error: "LLM API key not configured" }; + } + + const { + model, + messages, + jsonSchema, + maxRetries = 0, + retryDelayMs = 500, + } = options; + const jobId = options.jobId; + + const cacheKey = buildModeCacheKey(this.provider, this.baseUrl); + const modes = getOrderedModes(cacheKey, this.strategy.modes); + + for (const mode of modes) { + const result = await this.tryMode({ + mode, + model, + messages, + jsonSchema, + maxRetries, + retryDelayMs, + jobId, + }); + + if (result.success) { + rememberSuccessfulMode(cacheKey, mode); + return result; + } + + if (!result.success && result.error.startsWith("CAPABILITY:")) { + continue; + } + + return result; + } + + return { success: false, error: "All provider modes failed" }; + } + + getProvider(): LlmProvider { + return this.provider; + } + + getBaseUrl(): string { + return this.baseUrl; + } + + async validateCredentials(): Promise { + if (this.strategy.requiresApiKey && !this.apiKey) { + return { valid: false, message: "LLM API key is missing." }; + } + + const urls = this.strategy.getValidationUrls({ + baseUrl: this.baseUrl, + apiKey: this.apiKey, + }); + let lastMessage: string | null = null; + + for (const url of urls) { + try { + const response = await fetch(url, { + method: "GET", + headers: buildHeaders({ + apiKey: this.apiKey, + provider: this.provider, + }), + }); + + if (response.ok) { + return { valid: true, message: null }; + } + + const detail = await getResponseDetail(response); + if (response.status === 401) { + return { + valid: false, + message: "Invalid LLM API key. Check the key and try again.", + }; + } + + lastMessage = detail || `LLM provider returned ${response.status}`; + } catch (error) { + lastMessage = + error instanceof Error ? error.message : "LLM validation failed."; + } + } + + return { + valid: false, + message: lastMessage || "LLM provider validation failed.", + }; + } + + private async tryMode(args: { + mode: ResponseMode; + model: string; + messages: LlmRequestOptions["messages"]; + jsonSchema: JsonSchemaDefinition; + maxRetries: number; + retryDelayMs: number; + jobId?: string; + }): Promise> { + const { mode, model, messages, jsonSchema, maxRetries, retryDelayMs } = + args; + const jobId = args.jobId; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + console.log( + `🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`, + ); + await sleep(getRetryDelayMs(retryDelayMs, attempt)); + } + + const { url, headers, body } = this.strategy.buildRequest({ + mode, + baseUrl: this.baseUrl, + apiKey: this.apiKey, + model, + messages, + jsonSchema, + }); + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => "No error body"); + const parsedError = parseErrorMessage(errorBody); + const detail = parsedError ? ` - ${truncate(parsedError, 400)}` : ""; + const err = new Error( + `LLM API error: ${response.status}${detail}`, + ) as LlmApiError; + err.status = response.status; + err.body = errorBody; + throw err; + } + + const data = await response.json(); + const content = this.strategy.extractText(data); + + if (!content) { + throw new Error("No content in response"); + } + + const parsed = parseJsonContent(content, jobId); + return { success: true, data: parsed }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = (error as LlmApiError).status; + const body = (error as LlmApiError).body; + + if ( + this.strategy.isCapabilityError({ + mode, + status, + body, + }) + ) { + return { success: false, error: `CAPABILITY:${message}` }; + } + + if (attempt < maxRetries && shouldRetryAttempt({ message, status })) { + console.warn( + `⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`, + ); + continue; + } + + return { success: false, error: message }; + } + } + + return { success: false, error: "All retry attempts failed" }; + } +} + +function normalizeProvider( + raw: string | null, + baseUrl: string | null, +): LlmProvider { + const normalized = raw?.trim().toLowerCase(); + if (normalized === "openai_compatible") { + if ( + baseUrl?.includes("localhost:1234") || + baseUrl?.includes("127.0.0.1:1234") + ) { + return "lmstudio"; + } + return "openai"; + } + if (normalized === "openai") return "openai"; + if (normalized === "gemini") return "gemini"; + if (normalized === "lmstudio") return "lmstudio"; + if (normalized === "ollama") return "ollama"; + if (normalized && normalized !== "openrouter") { + console.warn( + `⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`, + ); + } + return "openrouter"; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/orchestrator/src/server/services/llm/types.ts b/orchestrator/src/server/services/llm/types.ts new file mode 100644 index 0000000..98f3f9e --- /dev/null +++ b/orchestrator/src/server/services/llm/types.ts @@ -0,0 +1,87 @@ +export type LlmProvider = + | "openrouter" + | "lmstudio" + | "ollama" + | "openai" + | "gemini"; + +export type ResponseMode = "json_schema" | "json_object" | "text" | "none"; + +export interface JsonSchemaDefinition { + name: string; + schema: { + type: "object"; + properties: Record; + required: string[]; + additionalProperties: boolean; + }; +} + +export interface LlmRequestOptions<_T> { + /** The model to use (e.g., 'google/gemini-3-flash-preview') */ + model: string; + /** The prompt messages to send */ + messages: Array<{ role: "user" | "system" | "assistant"; content: string }>; + /** JSON schema for structured output */ + jsonSchema: JsonSchemaDefinition; + /** Number of retries on parsing failures (default: 0) */ + maxRetries?: number; + /** Delay between retries in ms (default: 500) */ + retryDelayMs?: number; + /** Job ID for logging purposes */ + jobId?: string; +} + +export interface LlmResult { + success: true; + data: T; +} + +export interface LlmError { + success: false; + error: string; +} + +export type LlmResponse = LlmResult | LlmError; + +export type LlmValidationResult = { + valid: boolean; + message: string | null; +}; + +export type LlmServiceOptions = { + provider?: string | null; + baseUrl?: string | null; + apiKey?: string | null; +}; + +export type ProviderStrategy = { + provider: LlmProvider; + defaultBaseUrl: string; + requiresApiKey: boolean; + modes: ResponseMode[]; + validationPaths: string[]; + buildRequest: (args: { + mode: ResponseMode; + baseUrl: string; + apiKey: string | null; + model: string; + messages: LlmRequestOptions["messages"]; + jsonSchema: JsonSchemaDefinition; + }) => { url: string; headers: Record; body: unknown }; + extractText: (response: unknown) => string | null; + isCapabilityError: (args: { + mode: ResponseMode; + status?: number; + body?: string; + }) => boolean; + getValidationUrls: (args: { + baseUrl: string; + apiKey: string | null; + }) => string[]; +}; + +export interface LlmApiError extends Error { + status?: number; + body?: string; +} diff --git a/orchestrator/src/server/services/llm/utils/http.ts b/orchestrator/src/server/services/llm/utils/http.ts new file mode 100644 index 0000000..cf821f0 --- /dev/null +++ b/orchestrator/src/server/services/llm/utils/http.ts @@ -0,0 +1,51 @@ +import type { LlmProvider } from "../types"; + +export function joinUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +export function addQueryParam(url: string, key: string, value: string): string { + const connector = url.includes("?") ? "&" : "?"; + return `${url}${connector}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; +} + +export function buildHeaders(args: { + apiKey: string | null; + provider: LlmProvider; +}): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (args.apiKey) { + headers.Authorization = `Bearer ${args.apiKey}`; + } + + if (args.provider === "openrouter") { + headers["HTTP-Referer"] = "JobOps"; + headers["X-Title"] = "JobOpsOrchestrator"; + } + + return headers; +} + +export async function getResponseDetail(response: Response): Promise { + try { + const payload = await response.json(); + if (payload && typeof payload === "object" && "error" in payload) { + const errorObj = payload.error as { + message?: string; + code?: number | string; + }; + const message = errorObj?.message || ""; + const code = errorObj?.code ? ` (${errorObj.code})` : ""; + return `${message}${code}`.trim(); + } + } catch { + // ignore JSON parse errors + } + + return response.text().catch(() => ""); +} diff --git a/orchestrator/src/server/services/llm/utils/json.ts b/orchestrator/src/server/services/llm/utils/json.ts new file mode 100644 index 0000000..b892f17 --- /dev/null +++ b/orchestrator/src/server/services/llm/utils/json.ts @@ -0,0 +1,27 @@ +export function parseJsonContent(content: string, jobId?: string): T { + let candidate = content.trim(); + + candidate = candidate + .replace(/```(?:json|JSON)?\s*/g, "") + .replace(/```/g, "") + .trim(); + + const firstBrace = candidate.indexOf("{"); + const lastBrace = candidate.lastIndexOf("}"); + + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + candidate = candidate.substring(firstBrace, lastBrace + 1); + } + + try { + return JSON.parse(candidate) as T; + } catch (error) { + console.error( + `❌ [${jobId ?? "unknown"}] Failed to parse JSON:`, + candidate.substring(0, 200), + ); + throw new Error( + `Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`, + ); + } +} diff --git a/orchestrator/src/server/services/llm/utils/object.ts b/orchestrator/src/server/services/llm/utils/object.ts new file mode 100644 index 0000000..e278f30 --- /dev/null +++ b/orchestrator/src/server/services/llm/utils/object.ts @@ -0,0 +1,19 @@ +type PathSegment = string | number; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function getNestedValue(value: unknown, path: PathSegment[]): unknown { + let current: unknown = value; + for (const segment of path) { + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + continue; + } + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} diff --git a/orchestrator/src/server/services/llm/utils/string.ts b/orchestrator/src/server/services/llm/utils/string.ts new file mode 100644 index 0000000..29fa6bf --- /dev/null +++ b/orchestrator/src/server/services/llm/utils/string.ts @@ -0,0 +1,37 @@ +import { getNestedValue } from "./object"; + +export function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 1)}…`; +} + +export function parseErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + + try { + const payload = JSON.parse(trimmed) as unknown; + const candidates: Array = [ + getNestedValue(payload, ["error", "message"]), + getNestedValue(payload, ["error", "error", "message"]), + getNestedValue(payload, ["error"]), + getNestedValue(payload, ["message"]), + getNestedValue(payload, ["detail"]), + getNestedValue(payload, ["msg"]), + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + + if (typeof payload === "string" && payload.trim()) { + return payload.trim(); + } + } catch { + // Not JSON + } + + return trimmed; +} diff --git a/orchestrator/src/server/services/settings-conversion.test.ts b/orchestrator/src/server/services/settings-conversion.test.ts new file mode 100644 index 0000000..3499021 --- /dev/null +++ b/orchestrator/src/server/services/settings-conversion.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resolveSettingValue, + serializeSettingValue, +} from "./settings-conversion"; + +const originalEnv = { ...process.env }; + +describe("settings-conversion", () => { + afterEach(() => { + vi.unstubAllEnvs(); + process.env = { ...originalEnv }; + }); + + it("round-trips numeric settings", () => { + const serialized = serializeSettingValue("ukvisajobsMaxJobs", 42); + expect(serialized).toBe("42"); + + const resolved = resolveSettingValue( + "ukvisajobsMaxJobs", + serialized ?? undefined, + ); + expect(resolved.overrideValue).toBe(42); + expect(resolved.value).toBe(42); + expect(resolved.defaultValue).toBe(50); + }); + + it("round-trips boolean bit settings", () => { + expect(serializeSettingValue("jobspyIsRemote", true)).toBe("1"); + expect(serializeSettingValue("jobspyIsRemote", false)).toBe("0"); + + expect(resolveSettingValue("jobspyIsRemote", "1").value).toBe(true); + expect(resolveSettingValue("jobspyIsRemote", "0").value).toBe(false); + expect(resolveSettingValue("jobspyIsRemote", "true").value).toBe(true); + expect(resolveSettingValue("jobspyIsRemote", "false").value).toBe(false); + }); + + it("round-trips JSON array settings", () => { + const serialized = serializeSettingValue("searchTerms", [ + "backend", + "platform", + ]); + expect(serialized).toBe('["backend","platform"]'); + + const resolved = resolveSettingValue( + "searchTerms", + serialized ?? undefined, + ); + expect(resolved.overrideValue).toEqual(["backend", "platform"]); + expect(resolved.value).toEqual(["backend", "platform"]); + }); + + it("uses string defaults when override is empty", () => { + process.env.JOBSPY_LOCATION = "Remote"; + const resolved = resolveSettingValue("jobspyLocation", ""); + expect(resolved.defaultValue).toBe("Remote"); + expect(resolved.overrideValue).toBe(""); + expect(resolved.value).toBe("Remote"); + }); + + it("applies clamped backup value parsing", () => { + expect(resolveSettingValue("backupHour", "26").value).toBe(23); + expect(resolveSettingValue("backupMaxCount", "0").value).toBe(1); + }); + + it("falls back to default for invalid numeric overrides", () => { + const resolved = resolveSettingValue("ukvisajobsMaxJobs", "not-a-number"); + expect(resolved.overrideValue).toBeNull(); + expect(resolved.value).toBe(50); + }); + + it("falls back to default for invalid JSON array overrides", () => { + const objectOverride = resolveSettingValue("searchTerms", '{"term":"x"}'); + expect(objectOverride.overrideValue).toBeNull(); + expect(objectOverride.value).toEqual(["web developer"]); + + const malformedOverride = resolveSettingValue("searchTerms", "[oops"); + expect(malformedOverride.overrideValue).toBeNull(); + expect(malformedOverride.value).toEqual(["web developer"]); + }); +}); diff --git a/orchestrator/src/server/services/settings-conversion.ts b/orchestrator/src/server/services/settings-conversion.ts new file mode 100644 index 0000000..8360420 --- /dev/null +++ b/orchestrator/src/server/services/settings-conversion.ts @@ -0,0 +1,223 @@ +type SettingMetadata = { + defaultValue: () => T; + parseOverride: (raw: string | undefined) => T | null; + serialize: (value: Input) => string | null; + resolve: (args: { defaultValue: T; overrideValue: T | null }) => T; +}; + +type SettingsConversionValueMap = { + ukvisajobsMaxJobs: number; + gradcrackerMaxJobsPerTerm: number; + searchTerms: string[]; + jobspyLocation: string; + jobspyResultsWanted: number; + jobspyHoursOld: number; + jobspyCountryIndeed: string; + jobspySites: string[]; + jobspyLinkedinFetchDescription: boolean; + jobspyIsRemote: boolean; + showSponsorInfo: boolean; + backupEnabled: boolean; + backupHour: number; + backupMaxCount: number; +}; + +type SettingsConversionInputMap = { + [K in keyof SettingsConversionValueMap]: + | SettingsConversionValueMap[K] + | null + | undefined; +}; + +type SettingsConversionMetadata = { + [K in keyof SettingsConversionValueMap]: SettingMetadata< + SettingsConversionValueMap[K], + SettingsConversionInputMap[K] + >; +}; + +export type SettingsConversionKey = keyof SettingsConversionValueMap; + +function parseIntOrNull(raw: string | undefined): number | null { + if (!raw) return null; + const parsed = parseInt(raw, 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function parseJsonArrayOrNull(raw: string | undefined): string[] | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as string[]) : null; + } catch { + return null; + } +} + +function parseBitBoolOrNull(raw: string | undefined): boolean | null { + if (!raw) return null; + return raw === "true" || raw === "1"; +} + +function serializeNullableNumber( + value: number | null | undefined, +): string | null { + return value !== null && value !== undefined ? String(value) : null; +} + +function serializeNullableJsonArray( + value: string[] | null | undefined, +): string | null { + return value !== null && value !== undefined ? JSON.stringify(value) : null; +} + +function serializeBitBool(value: boolean | null | undefined): string | null { + if (value === null || value === undefined) return null; + return value ? "1" : "0"; +} + +function resolveWithNullishFallback(args: { + defaultValue: T; + overrideValue: T | null; +}): T { + return args.overrideValue ?? args.defaultValue; +} + +function resolveWithEmptyStringFallback(args: { + defaultValue: string; + overrideValue: string | null; +}): string { + return args.overrideValue || args.defaultValue; +} + +export const settingsConversionMetadata: SettingsConversionMetadata = { + ukvisajobsMaxJobs: { + defaultValue: () => 50, + parseOverride: parseIntOrNull, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, + gradcrackerMaxJobsPerTerm: { + defaultValue: () => 50, + parseOverride: parseIntOrNull, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, + searchTerms: { + defaultValue: () => + (process.env.JOBSPY_SEARCH_TERMS || "web developer") + .split("|") + .map((value) => value.trim()) + .filter(Boolean), + parseOverride: parseJsonArrayOrNull, + serialize: serializeNullableJsonArray, + resolve: resolveWithNullishFallback, + }, + jobspyLocation: { + defaultValue: () => process.env.JOBSPY_LOCATION || "UK", + parseOverride: (raw) => raw ?? null, + serialize: (value) => value ?? null, + resolve: resolveWithEmptyStringFallback, + }, + jobspyResultsWanted: { + defaultValue: () => + parseInt(process.env.JOBSPY_RESULTS_WANTED || "200", 10), + parseOverride: parseIntOrNull, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, + jobspyHoursOld: { + defaultValue: () => parseInt(process.env.JOBSPY_HOURS_OLD || "72", 10), + parseOverride: parseIntOrNull, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, + jobspyCountryIndeed: { + defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK", + parseOverride: (raw) => raw ?? null, + serialize: (value) => value ?? null, + resolve: resolveWithEmptyStringFallback, + }, + jobspySites: { + defaultValue: () => + (process.env.JOBSPY_SITES || "indeed,linkedin") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + parseOverride: parseJsonArrayOrNull, + serialize: serializeNullableJsonArray, + resolve: resolveWithNullishFallback, + }, + jobspyLinkedinFetchDescription: { + defaultValue: () => + (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1", + parseOverride: parseBitBoolOrNull, + serialize: serializeBitBool, + resolve: resolveWithNullishFallback, + }, + jobspyIsRemote: { + defaultValue: () => (process.env.JOBSPY_IS_REMOTE || "0") === "1", + parseOverride: parseBitBoolOrNull, + serialize: serializeBitBool, + resolve: resolveWithNullishFallback, + }, + showSponsorInfo: { + defaultValue: () => true, + parseOverride: parseBitBoolOrNull, + serialize: serializeBitBool, + resolve: resolveWithNullishFallback, + }, + backupEnabled: { + defaultValue: () => false, + parseOverride: parseBitBoolOrNull, + serialize: serializeBitBool, + resolve: resolveWithNullishFallback, + }, + backupHour: { + defaultValue: () => 2, + parseOverride: (raw) => { + const parsed = raw ? parseInt(raw, 10) : NaN; + if (Number.isNaN(parsed)) return null; + return Math.min(23, Math.max(0, parsed)); + }, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, + backupMaxCount: { + defaultValue: () => 5, + parseOverride: (raw) => { + const parsed = raw ? parseInt(raw, 10) : NaN; + if (Number.isNaN(parsed)) return null; + return Math.min(5, Math.max(1, parsed)); + }, + serialize: serializeNullableNumber, + resolve: resolveWithNullishFallback, + }, +}; + +export function resolveSettingValue( + key: K, + raw: string | undefined, +): { + defaultValue: SettingsConversionValueMap[K]; + overrideValue: SettingsConversionValueMap[K] | null; + value: SettingsConversionValueMap[K]; +} { + const metadata = settingsConversionMetadata[key]; + const defaultValue = metadata.defaultValue(); + const overrideValue = metadata.parseOverride(raw); + const value = metadata.resolve({ + defaultValue, + overrideValue, + }); + + return { defaultValue, overrideValue, value }; +} + +export function serializeSettingValue( + key: K, + value: SettingsConversionInputMap[K], +): string | null { + const metadata = settingsConversionMetadata[key]; + return metadata.serialize(value); +} diff --git a/orchestrator/src/server/services/settings-update/apply-updates.test.ts b/orchestrator/src/server/services/settings-update/apply-updates.test.ts new file mode 100644 index 0000000..dfe1032 --- /dev/null +++ b/orchestrator/src/server/services/settings-update/apply-updates.test.ts @@ -0,0 +1,149 @@ +import type { ResumeProjectsSettingsInput } from "@shared/settings-schema"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { applySettingsUpdates } from "./apply-updates"; + +vi.mock("@server/repositories/settings", () => ({ + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@server/services/envSettings", () => ({ + applyEnvValue: vi.fn(), + normalizeEnvInput: (value: string | null | undefined) => { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; + }, +})); + +vi.mock("@server/services/profile", () => ({ + getProfile: vi.fn(), +})); + +vi.mock("@server/services/resumeProjects", () => ({ + extractProjectsFromProfile: vi.fn(), + normalizeResumeProjectsSettings: vi.fn(), +})); + +describe("applySettingsUpdates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies representative handlers and env side effects", async () => { + const settingsRepo = await import("@server/repositories/settings"); + const envSettings = await import("@server/services/envSettings"); + + const plan = await applySettingsUpdates({ + model: "gpt-4o-mini", + ukvisajobsMaxJobs: 42, + searchTerms: ["backend", "platform"], + jobspyIsRemote: true, + llmProvider: "openai", + }); + + expect(settingsRepo.setSetting).toHaveBeenCalledTimes(5); + expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual( + expect.arrayContaining([ + ["model", "gpt-4o-mini"], + ["ukvisajobsMaxJobs", "42"], + ["searchTerms", '["backend","platform"]'], + ["jobspyIsRemote", "1"], + ["llmProvider", "openai"], + ]), + ); + expect(envSettings.applyEnvValue).toHaveBeenCalledWith( + "LLM_PROVIDER", + "openai", + ); + expect(plan.shouldRefreshBackupScheduler).toBe(false); + }); + + it("handles deprecated openrouterApiKey migration path", async () => { + const settingsRepo = await import("@server/repositories/settings"); + const envSettings = await import("@server/services/envSettings"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await applySettingsUpdates({ + openrouterApiKey: " legacy-key ", + }); + + expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual( + expect.arrayContaining([ + ["llmApiKey", "legacy-key"], + ["openrouterApiKey", null], + ]), + ); + expect(envSettings.applyEnvValue).toHaveBeenCalledWith( + "LLM_API_KEY", + "legacy-key", + ); + expect(envSettings.applyEnvValue).toHaveBeenCalledWith( + "OPENROUTER_API_KEY", + null, + ); + expect(warnSpy).toHaveBeenCalledOnce(); + warnSpy.mockRestore(); + }); + + it("marks backup scheduler refresh when backup settings are changed", async () => { + const settingsRepo = await import("@server/repositories/settings"); + + const plan = await applySettingsUpdates({ + backupEnabled: false, + backupHour: 4, + }); + + expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual( + expect.arrayContaining([ + ["backupEnabled", "0"], + ["backupHour", "4"], + ]), + ); + expect(plan.shouldRefreshBackupScheduler).toBe(true); + }); + + it("resolves and persists normalized resumeProjects", async () => { + const settingsRepo = await import("@server/repositories/settings"); + const profileService = await import("@server/services/profile"); + const resumeProjectsService = await import( + "@server/services/resumeProjects" + ); + + const input: ResumeProjectsSettingsInput = { + maxProjects: 2, + lockedProjectIds: ["proj-1"], + aiSelectableProjectIds: ["proj-2"], + }; + const normalized: ResumeProjectsSettingsInput = { + maxProjects: 1, + lockedProjectIds: ["proj-1"], + aiSelectableProjectIds: [], + }; + + vi.mocked(profileService.getProfile).mockResolvedValue({} as any); + vi.mocked(resumeProjectsService.extractProjectsFromProfile).mockReturnValue( + { + catalog: [{ id: "proj-1" }, { id: "proj-2" }] as any, + selectionItems: [], + }, + ); + vi.mocked( + resumeProjectsService.normalizeResumeProjectsSettings, + ).mockReturnValue(normalized as any); + + await applySettingsUpdates({ resumeProjects: input }); + + expect(profileService.getProfile).toHaveBeenCalledOnce(); + const allowedSet = vi.mocked( + resumeProjectsService.normalizeResumeProjectsSettings, + ).mock.calls[0]?.[1]; + expect(allowedSet).toBeInstanceOf(Set); + expect(Array.from(allowedSet as Set).sort()).toEqual([ + "proj-1", + "proj-2", + ]); + expect(vi.mocked(settingsRepo.setSetting)).toHaveBeenCalledWith( + "resumeProjects", + JSON.stringify(normalized), + ); + }); +}); diff --git a/orchestrator/src/server/services/settings-update/apply-updates.ts b/orchestrator/src/server/services/settings-update/apply-updates.ts new file mode 100644 index 0000000..07fbb53 --- /dev/null +++ b/orchestrator/src/server/services/settings-update/apply-updates.ts @@ -0,0 +1,46 @@ +import type { UpdateSettingsInput } from "@shared/settings-schema"; +import type { + DeferredSideEffect, + SettingsUpdateAction, + SettingsUpdateContext, + SettingsUpdatePlan, + SettingUpdateHandler, +} from "./registry"; +import { settingsUpdateRegistry } from "./registry"; + +async function runAction(action: SettingsUpdateAction): Promise { + await action.persist(); + if (action.sideEffect) { + await action.sideEffect(); + } +} + +export async function applySettingsUpdates( + input: UpdateSettingsInput, +): Promise { + const context: SettingsUpdateContext = { input }; + const actions: SettingsUpdateAction[] = []; + const deferredSideEffects = new Set(); + + const keys = Object.keys(input) as Array; + for (const key of keys) { + const handler = settingsUpdateRegistry[key] as + | SettingUpdateHandler + | undefined; + if (!handler) continue; + + const result = await handler({ key, value: input[key], context }); + actions.push(...result.actions); + for (const deferred of result.deferredSideEffects) { + deferredSideEffects.add(deferred); + } + } + + await Promise.all(actions.map(runAction)); + + return { + shouldRefreshBackupScheduler: deferredSideEffects.has( + "refreshBackupScheduler", + ), + }; +} diff --git a/orchestrator/src/server/services/settings-update/index.ts b/orchestrator/src/server/services/settings-update/index.ts new file mode 100644 index 0000000..c42997e --- /dev/null +++ b/orchestrator/src/server/services/settings-update/index.ts @@ -0,0 +1,16 @@ +export { applySettingsUpdates } from "./apply-updates"; +export type { + DeferredSideEffect, + SettingsUpdateAction, + SettingsUpdateContext, + SettingsUpdatePlan, + SettingsUpdateResult, + SettingUpdateHandler, +} from "./registry"; +export { + settingsUpdateRegistry, + toBitBoolOrNull, + toJsonOrNull, + toNormalizedStringOrNull, + toNumberStringOrNull, +} from "./registry"; diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts new file mode 100644 index 0000000..b7d3edb --- /dev/null +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -0,0 +1,329 @@ +import type { SettingKey } from "@server/repositories/settings"; +import * as settingsRepo from "@server/repositories/settings"; +import { applyEnvValue, normalizeEnvInput } from "@server/services/envSettings"; +import { getProfile } from "@server/services/profile"; +import { + extractProjectsFromProfile, + normalizeResumeProjectsSettings, +} from "@server/services/resumeProjects"; +import { + type SettingsConversionKey, + serializeSettingValue, +} from "@server/services/settings-conversion"; +import type { UpdateSettingsInput } from "@shared/settings-schema"; + +export type DeferredSideEffect = "refreshBackupScheduler"; + +export type SettingsUpdateAction = { + settingKey: SettingKey; + persist: () => Promise; + sideEffect?: () => void | Promise; +}; + +export type SettingsUpdateResult = { + actions: SettingsUpdateAction[]; + deferredSideEffects: Set; +}; + +export type SettingsUpdateContext = { + input: UpdateSettingsInput; +}; + +export type SettingUpdateHandler = (args: { + key: K; + value: UpdateSettingsInput[K]; + context: SettingsUpdateContext; +}) => Promise | SettingsUpdateResult; + +export type SettingsUpdatePlan = { + shouldRefreshBackupScheduler: boolean; +}; + +export function toNormalizedStringOrNull( + value: string | null | undefined, +): string | null { + return normalizeEnvInput(value); +} + +export function toNumberStringOrNull( + value: number | null | undefined, +): string | null { + return serializeSettingValue("ukvisajobsMaxJobs", value); +} + +export function toJsonOrNull(value: T | null | undefined): string | null { + return value !== null && value !== undefined ? JSON.stringify(value) : null; +} + +export function toBitBoolOrNull( + value: boolean | null | undefined, +): string | null { + return serializeSettingValue("jobspyIsRemote", value); +} + +function result( + args: { + actions?: SettingsUpdateAction[]; + deferred?: DeferredSideEffect[]; + } = {}, +): SettingsUpdateResult { + return { + actions: args.actions ?? [], + deferredSideEffects: new Set(args.deferred ?? []), + }; +} + +function persistAction( + settingKey: Parameters[0], + value: string | null, + sideEffect?: () => void | Promise, +): SettingsUpdateAction { + return { + settingKey, + persist: () => settingsRepo.setSetting(settingKey, value), + sideEffect, + }; +} + +function singleAction( + fn: SettingUpdateHandler, +): SettingUpdateHandler { + return fn; +} + +function metadataPersistAction( + key: SettingsConversionKey, + value: unknown, +): SettingsUpdateAction { + return persistAction(key, serializeSettingValue(key, value as never)); +} + +export const settingsUpdateRegistry: Partial<{ + [K in keyof UpdateSettingsInput]: SettingUpdateHandler; +}> = { + model: singleAction(({ value }) => + result({ actions: [persistAction("model", value ?? null)] }), + ), + modelScorer: singleAction(({ value }) => + result({ actions: [persistAction("modelScorer", value ?? null)] }), + ), + modelTailoring: singleAction(({ value }) => + result({ actions: [persistAction("modelTailoring", value ?? null)] }), + ), + modelProjectSelection: singleAction(({ value }) => + result({ + actions: [persistAction("modelProjectSelection", value ?? null)], + }), + ), + llmProvider: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("llmProvider", normalized, () => { + applyEnvValue("LLM_PROVIDER", normalized); + }), + ], + }); + }), + llmBaseUrl: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("llmBaseUrl", normalized, () => { + applyEnvValue("LLM_BASE_URL", normalized); + }), + ], + }); + }), + pipelineWebhookUrl: singleAction(({ value }) => + result({ actions: [persistAction("pipelineWebhookUrl", value ?? null)] }), + ), + jobCompleteWebhookUrl: singleAction(({ value }) => + result({ + actions: [persistAction("jobCompleteWebhookUrl", value ?? null)], + }), + ), + rxresumeBaseResumeId: singleAction(({ value }) => + result({ + actions: [ + persistAction("rxresumeBaseResumeId", toNormalizedStringOrNull(value)), + ], + }), + ), + resumeProjects: singleAction(async ({ value }) => { + const resumeProjects = value ?? null; + if (resumeProjects === null) { + return result({ actions: [persistAction("resumeProjects", null)] }); + } + + const profile = await getProfile(); + const { catalog } = extractProjectsFromProfile(profile); + const allowed = new Set(catalog.map((project) => project.id)); + const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); + + return result({ + actions: [persistAction("resumeProjects", JSON.stringify(normalized))], + }); + }), + ukvisajobsMaxJobs: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("ukvisajobsMaxJobs", value)], + }), + ), + gradcrackerMaxJobsPerTerm: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("gradcrackerMaxJobsPerTerm", value)], + }), + ), + searchTerms: singleAction(({ value }) => + result({ actions: [metadataPersistAction("searchTerms", value)] }), + ), + jobspyLocation: singleAction(({ value }) => + result({ actions: [metadataPersistAction("jobspyLocation", value)] }), + ), + jobspyResultsWanted: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("jobspyResultsWanted", value)], + }), + ), + jobspyHoursOld: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("jobspyHoursOld", value)], + }), + ), + jobspyCountryIndeed: singleAction(({ value }) => + result({ actions: [metadataPersistAction("jobspyCountryIndeed", value)] }), + ), + jobspySites: singleAction(({ value }) => + result({ actions: [metadataPersistAction("jobspySites", value)] }), + ), + jobspyLinkedinFetchDescription: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("jobspyLinkedinFetchDescription", value)], + }), + ), + jobspyIsRemote: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("jobspyIsRemote", value)], + }), + ), + showSponsorInfo: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("showSponsorInfo", value)], + }), + ), + openrouterApiKey: singleAction(({ value }) => { + console.warn( + "[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.", + ); + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("llmApiKey", normalized, () => { + applyEnvValue("LLM_API_KEY", normalized); + }), + persistAction("openrouterApiKey", null, () => { + applyEnvValue("OPENROUTER_API_KEY", null); + }), + ], + }); + }), + llmApiKey: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("llmApiKey", normalized, () => { + applyEnvValue("LLM_API_KEY", normalized); + }), + ], + }); + }), + rxresumeEmail: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("rxresumeEmail", normalized, () => { + applyEnvValue("RXRESUME_EMAIL", normalized); + }), + ], + }); + }), + rxresumePassword: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("rxresumePassword", normalized, () => { + applyEnvValue("RXRESUME_PASSWORD", normalized); + }), + ], + }); + }), + basicAuthUser: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("basicAuthUser", normalized, () => { + applyEnvValue("BASIC_AUTH_USER", normalized); + }), + ], + }); + }), + basicAuthPassword: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("basicAuthPassword", normalized, () => { + applyEnvValue("BASIC_AUTH_PASSWORD", normalized); + }), + ], + }); + }), + ukvisajobsEmail: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("ukvisajobsEmail", normalized, () => { + applyEnvValue("UKVISAJOBS_EMAIL", normalized); + }), + ], + }); + }), + ukvisajobsPassword: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("ukvisajobsPassword", normalized, () => { + applyEnvValue("UKVISAJOBS_PASSWORD", normalized); + }), + ], + }); + }), + webhookSecret: singleAction(({ value }) => { + const normalized = toNormalizedStringOrNull(value); + return result({ + actions: [ + persistAction("webhookSecret", normalized, () => { + applyEnvValue("WEBHOOK_SECRET", normalized); + }), + ], + }); + }), + backupEnabled: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("backupEnabled", value)], + deferred: ["refreshBackupScheduler"], + }), + ), + backupHour: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("backupHour", value)], + deferred: ["refreshBackupScheduler"], + }), + ), + backupMaxCount: singleAction(({ value }) => + result({ + actions: [metadataPersistAction("backupMaxCount", value)], + deferred: ["refreshBackupScheduler"], + }), + ), +}; diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 1f981cc..fd73f18 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -7,6 +7,7 @@ import { resolveResumeProjectsSettings, } from "./resumeProjects"; import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; +import { resolveSettingValue } from "./settings-conversion"; /** * Get the effective app settings, combining environment variables and database overrides. @@ -87,130 +88,122 @@ export async function getEffectiveSettings(): Promise { overrideRaw: overrideResumeProjectsRaw, }); - const defaultUkvisajobsMaxJobs = 50; - const overrideUkvisajobsMaxJobsRaw = overrides.ukvisajobsMaxJobs; - const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw - ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) - : null; - const ukvisajobsMaxJobs = - overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; - - const defaultGradcrackerMaxJobsPerTerm = 50; - const overrideGradcrackerMaxJobsPerTermRaw = - overrides.gradcrackerMaxJobsPerTerm; - const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw - ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) - : null; - const gradcrackerMaxJobsPerTerm = - overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; - - const defaultSearchTermsEnv = - process.env.JOBSPY_SEARCH_TERMS || "web developer"; - const defaultSearchTerms = defaultSearchTermsEnv - .split("|") - .map((s) => s.trim()) - .filter(Boolean); - const overrideSearchTermsRaw = overrides.searchTerms; - const overrideSearchTerms = overrideSearchTermsRaw - ? (JSON.parse(overrideSearchTermsRaw) as string[]) - : null; - const searchTerms = overrideSearchTerms ?? defaultSearchTerms; - - const defaultJobspyLocation = process.env.JOBSPY_LOCATION || "UK"; - const overrideJobspyLocation = overrides.jobspyLocation ?? null; - const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; - - const defaultJobspyResultsWanted = parseInt( - process.env.JOBSPY_RESULTS_WANTED || "200", - 10, + const ukvisajobsMaxJobsSetting = resolveSettingValue( + "ukvisajobsMaxJobs", + overrides.ukvisajobsMaxJobs, ); - const overrideJobspyResultsWantedRaw = overrides.jobspyResultsWanted; - const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw - ? parseInt(overrideJobspyResultsWantedRaw, 10) - : null; - const jobspyResultsWanted = - overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; + const defaultUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.defaultValue; + const overrideUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.overrideValue; + const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting.value; - const defaultJobspyHoursOld = parseInt( - process.env.JOBSPY_HOURS_OLD || "72", - 10, + const gradcrackerMaxJobsPerTermSetting = resolveSettingValue( + "gradcrackerMaxJobsPerTerm", + overrides.gradcrackerMaxJobsPerTerm, ); - const overrideJobspyHoursOldRaw = overrides.jobspyHoursOld; - const overrideJobspyHoursOld = overrideJobspyHoursOldRaw - ? parseInt(overrideJobspyHoursOldRaw, 10) - : null; - const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; + const defaultGradcrackerMaxJobsPerTerm = + gradcrackerMaxJobsPerTermSetting.defaultValue; + const overrideGradcrackerMaxJobsPerTerm = + gradcrackerMaxJobsPerTermSetting.overrideValue; + const gradcrackerMaxJobsPerTerm = gradcrackerMaxJobsPerTermSetting.value; - const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || "UK"; - const overrideJobspyCountryIndeed = overrides.jobspyCountryIndeed ?? null; - const jobspyCountryIndeed = - overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; + const searchTermsSetting = resolveSettingValue( + "searchTerms", + overrides.searchTerms, + ); + const defaultSearchTerms = searchTermsSetting.defaultValue; + const overrideSearchTerms = searchTermsSetting.overrideValue; + const searchTerms = searchTermsSetting.value; - const defaultJobspySites = (process.env.JOBSPY_SITES || "indeed,linkedin") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const overrideJobspySitesRaw = overrides.jobspySites; - const overrideJobspySites = overrideJobspySitesRaw - ? (JSON.parse(overrideJobspySitesRaw) as string[]) - : null; - const jobspySites = overrideJobspySites ?? defaultJobspySites; + const jobspyLocationSetting = resolveSettingValue( + "jobspyLocation", + overrides.jobspyLocation, + ); + const defaultJobspyLocation = jobspyLocationSetting.defaultValue; + const overrideJobspyLocation = jobspyLocationSetting.overrideValue; + const jobspyLocation = jobspyLocationSetting.value; + const jobspyResultsWantedSetting = resolveSettingValue( + "jobspyResultsWanted", + overrides.jobspyResultsWanted, + ); + const defaultJobspyResultsWanted = jobspyResultsWantedSetting.defaultValue; + const overrideJobspyResultsWanted = jobspyResultsWantedSetting.overrideValue; + const jobspyResultsWanted = jobspyResultsWantedSetting.value; + + const jobspyHoursOldSetting = resolveSettingValue( + "jobspyHoursOld", + overrides.jobspyHoursOld, + ); + const defaultJobspyHoursOld = jobspyHoursOldSetting.defaultValue; + const overrideJobspyHoursOld = jobspyHoursOldSetting.overrideValue; + const jobspyHoursOld = jobspyHoursOldSetting.value; + + const jobspyCountryIndeedSetting = resolveSettingValue( + "jobspyCountryIndeed", + overrides.jobspyCountryIndeed, + ); + const defaultJobspyCountryIndeed = jobspyCountryIndeedSetting.defaultValue; + const overrideJobspyCountryIndeed = jobspyCountryIndeedSetting.overrideValue; + const jobspyCountryIndeed = jobspyCountryIndeedSetting.value; + + const jobspySitesSetting = resolveSettingValue( + "jobspySites", + overrides.jobspySites, + ); + const defaultJobspySites = jobspySitesSetting.defaultValue; + const overrideJobspySites = jobspySitesSetting.overrideValue; + const jobspySites = jobspySitesSetting.value; + + const jobspyLinkedinFetchDescriptionSetting = resolveSettingValue( + "jobspyLinkedinFetchDescription", + overrides.jobspyLinkedinFetchDescription, + ); const defaultJobspyLinkedinFetchDescription = - (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1"; - const overrideJobspyLinkedinFetchDescriptionRaw = - overrides.jobspyLinkedinFetchDescription; + jobspyLinkedinFetchDescriptionSetting.defaultValue; const overrideJobspyLinkedinFetchDescription = - overrideJobspyLinkedinFetchDescriptionRaw - ? overrideJobspyLinkedinFetchDescriptionRaw === "true" || - overrideJobspyLinkedinFetchDescriptionRaw === "1" - : null; + jobspyLinkedinFetchDescriptionSetting.overrideValue; const jobspyLinkedinFetchDescription = - overrideJobspyLinkedinFetchDescription ?? - defaultJobspyLinkedinFetchDescription; + jobspyLinkedinFetchDescriptionSetting.value; - const defaultJobspyIsRemote = (process.env.JOBSPY_IS_REMOTE || "0") === "1"; - const overrideJobspyIsRemoteRaw = overrides.jobspyIsRemote; - const overrideJobspyIsRemote = overrideJobspyIsRemoteRaw - ? overrideJobspyIsRemoteRaw === "true" || overrideJobspyIsRemoteRaw === "1" - : null; - const jobspyIsRemote = overrideJobspyIsRemote ?? defaultJobspyIsRemote; + const jobspyIsRemoteSetting = resolveSettingValue( + "jobspyIsRemote", + overrides.jobspyIsRemote, + ); + const defaultJobspyIsRemote = jobspyIsRemoteSetting.defaultValue; + const overrideJobspyIsRemote = jobspyIsRemoteSetting.overrideValue; + const jobspyIsRemote = jobspyIsRemoteSetting.value; - const defaultShowSponsorInfo = true; - const overrideShowSponsorInfoRaw = overrides.showSponsorInfo; - const overrideShowSponsorInfo = overrideShowSponsorInfoRaw - ? overrideShowSponsorInfoRaw === "true" || - overrideShowSponsorInfoRaw === "1" - : null; - const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; + const showSponsorInfoSetting = resolveSettingValue( + "showSponsorInfo", + overrides.showSponsorInfo, + ); + const defaultShowSponsorInfo = showSponsorInfoSetting.defaultValue; + const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue; + const showSponsorInfo = showSponsorInfoSetting.value; - // Backup settings - const defaultBackupEnabled = false; - const overrideBackupEnabledRaw = overrides.backupEnabled; - const overrideBackupEnabled = overrideBackupEnabledRaw - ? overrideBackupEnabledRaw === "true" || overrideBackupEnabledRaw === "1" - : null; - const backupEnabled = overrideBackupEnabled ?? defaultBackupEnabled; + const backupEnabledSetting = resolveSettingValue( + "backupEnabled", + overrides.backupEnabled, + ); + const defaultBackupEnabled = backupEnabledSetting.defaultValue; + const overrideBackupEnabled = backupEnabledSetting.overrideValue; + const backupEnabled = backupEnabledSetting.value; - const defaultBackupHour = 2; - const overrideBackupHourRaw = overrides.backupHour; - const parsedBackupHour = overrideBackupHourRaw - ? parseInt(overrideBackupHourRaw, 10) - : NaN; - const overrideBackupHour = Number.isNaN(parsedBackupHour) - ? null - : Math.min(23, Math.max(0, parsedBackupHour)); - const backupHour = overrideBackupHour ?? defaultBackupHour; + const backupHourSetting = resolveSettingValue( + "backupHour", + overrides.backupHour, + ); + const defaultBackupHour = backupHourSetting.defaultValue; + const overrideBackupHour = backupHourSetting.overrideValue; + const backupHour = backupHourSetting.value; - const defaultBackupMaxCount = 5; - const overrideBackupMaxCountRaw = overrides.backupMaxCount; - const parsedBackupMaxCount = overrideBackupMaxCountRaw - ? parseInt(overrideBackupMaxCountRaw, 10) - : NaN; - const overrideBackupMaxCount = Number.isNaN(parsedBackupMaxCount) - ? null - : Math.min(5, Math.max(1, parsedBackupMaxCount)); - const backupMaxCount = overrideBackupMaxCount ?? defaultBackupMaxCount; + const backupMaxCountSetting = resolveSettingValue( + "backupMaxCount", + overrides.backupMaxCount, + ); + const defaultBackupMaxCount = backupMaxCountSetting.defaultValue; + const overrideBackupMaxCount = backupMaxCountSetting.overrideValue; + const backupMaxCount = backupMaxCountSetting.value; return { ...envSettings,