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
This commit is contained in:
Shaheer Sarfaraz 2026-02-04 21:48:28 +00:00 committed by GitHub
parent b94f85b149
commit 82b261c7bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2882 additions and 1734 deletions

View File

@ -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<Storage> | 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<string, string>();
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"]),
);

View File

@ -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<GradcrackerSectionProps> = ({
isLoading,
isSaving,
}) => {
const {
effective: effectiveGradcrackerMaxJobsPerTerm,
default: defaultGradcrackerMaxJobsPerTerm,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Gradcracker Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs per search term"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => {
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)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<NumericSettingSection
accordionValue="gradcracker"
title="Gradcracker Extractor"
fieldName="gradcrackerMaxJobsPerTerm"
label="Max jobs per search term"
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${values.default}. Range: 1-1000.`}
values={values}
min={1}
max={1000}
isLoading={isLoading}
isSaving={isSaving}
/>
);
};

View File

@ -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<UpdateSettingsInput>({
defaultValues: {
ukvisajobsMaxJobs: 50,
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["ukvisajobs"]}>
<NumericSettingSection
accordionValue="ukvisajobs"
title="UKVisaJobs Extractor"
fieldName="ukvisajobsMaxJobs"
label="Max jobs to fetch"
helper="Maximum jobs per run."
values={{ default: 50, effective: 50 }}
min={1}
max={1000}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
);
};
describe("NumericSettingSection", () => {
it("clamps out-of-range values and clears invalid number input", () => {
render(<Harness />);
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);
});
});

View File

@ -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<NumericSettingSectionProps> = ({
accordionValue,
title,
fieldName,
label,
helper,
values,
min,
max,
isLoading,
isSaving,
}) => {
const { effective, default: defaultValue } = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value={accordionValue} className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">{title}</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<Controller
name={fieldName}
control={control}
render={({ field }) => (
<SettingsInput
label={label}
type="number"
inputProps={{
...field,
inputMode: "numeric",
min,
max,
value: field.value ?? defaultValue,
onChange: (event) => {
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)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -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<UkvisajobsSectionProps> = ({
isLoading,
isSaving,
}) => {
const {
effective: effectiveUkvisajobsMaxJobs,
default: defaultUkvisajobsMaxJobs,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs to fetch"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => {
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)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<NumericSettingSection
accordionValue="ukvisajobs"
title="UKVisaJobs Extractor"
fieldName="ukvisajobsMaxJobs"
label="Max jobs to fetch"
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${values.default}. Range: 1-1000.`}
values={values}
min={1}
max={1000}
isLoading={isLoading}
isSaving={isSaving}
/>
);
};

View File

@ -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<void>[] = [];
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,

View File

@ -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<string, unknown>,
) {
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<string, string> = {
"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<string, unknown>;
});
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,
});

View File

@ -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");
});
});

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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";

View File

@ -0,0 +1,12 @@
import { getProfile } from "../../services/profile";
export async function loadProfileStep(): Promise<Record<string, unknown>> {
console.log("\n📋 Loading profile...");
return getProfile().catch((error) => {
console.warn(
"⚠️ Failed to load profile for scoring, using empty profile:",
error,
);
return {} as Record<string, unknown>;
});
}

View File

@ -0,0 +1,43 @@
import * as settingsRepo from "../../repositories/settings";
export async function notifyPipelineWebhookStep(
event: "pipeline.completed" | "pipeline.failed",
payload: Record<string, unknown>,
): Promise<void> {
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<string, string> = {
"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);
}
}

View File

@ -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 };
}

View File

@ -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<string, unknown>;
}): 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 };
}

View File

@ -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"]);
});
});

View File

@ -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);
}

View File

@ -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<string, unknown>;
discoveredJobs: CreateJobInput[];
sourceErrors: string[];
created: number;
skipped: number;
unprocessedJobs: Job[];
scoredJobs: ScoredJob[];
jobsToProcess: ScoredJob[];
processedCount: number;
};

View File

@ -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<string, unknown>;
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<T> {
success: true;
data: T;
}
export interface LlmError {
success: false;
error: string;
}
export type LlmResponse<T> = LlmResult<T> | 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<unknown>["messages"];
jsonSchema: JsonSchemaDefinition;
}) => { url: string; headers: Record<string, string>; 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<string, ResponseMode>();
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<LlmProvider, ProviderStrategy> = {
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<T>(options: LlmRequestOptions<T>): Promise<LlmResponse<T>> {
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<T>({
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<LlmValidationResult> {
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<T>(args: {
mode: ResponseMode;
model: string;
messages: LlmRequestOptions<T>["messages"];
jsonSchema: JsonSchemaDefinition;
maxRetries: number;
retryDelayMs: number;
jobId?: string;
}): Promise<LlmResponse<T>> {
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<T>(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<T>(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<string, string> {
const headers: Record<string, string> = {
"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<unknown>["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<unknown>["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<string> {
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<string, unknown> {
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<void> {
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<unknown> = [
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";

View File

@ -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);
});
});

View File

@ -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")
);
}

View File

@ -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",
]);
});
});

View File

@ -0,0 +1,28 @@
import type { ResponseMode } from "../types";
const modeCache = new Map<string, ResponseMode>();
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();
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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<unknown>["messages"];
jsonSchema: JsonSchemaDefinition;
extra?: Record<string, unknown>;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
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;
}

View File

@ -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<string, unknown> = {
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<unknown>["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 };
}

View File

@ -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<LlmProvider, ProviderStrategy> = {
openrouter: openRouterStrategy,
lmstudio: lmStudioStrategy,
ollama: ollamaStrategy,
openai: openAiStrategy,
gemini: geminiStrategy,
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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<string, unknown> = {
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<unknown>["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,
];
}

View File

@ -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,
});

View File

@ -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<string, unknown>;
expect(
(body.response_format as Record<string, unknown>).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");
});
});

View File

@ -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<T>(options: LlmRequestOptions<T>): Promise<LlmResponse<T>> {
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<T>({
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<LlmValidationResult> {
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<T>(args: {
mode: ResponseMode;
model: string;
messages: LlmRequestOptions<T>["messages"];
jsonSchema: JsonSchemaDefinition;
maxRetries: number;
retryDelayMs: number;
jobId?: string;
}): Promise<LlmResponse<T>> {
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<T>(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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -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<string, unknown>;
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<T> {
success: true;
data: T;
}
export interface LlmError {
success: false;
error: string;
}
export type LlmResponse<T> = LlmResult<T> | 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<unknown>["messages"];
jsonSchema: JsonSchemaDefinition;
}) => { url: string; headers: Record<string, string>; 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;
}

View File

@ -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<string, string> {
const headers: Record<string, string> = {
"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<string> {
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(() => "");
}

View File

@ -0,0 +1,27 @@
export function parseJsonContent<T>(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"}`,
);
}
}

View File

@ -0,0 +1,19 @@
type PathSegment = string | number;
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}

View File

@ -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<unknown> = [
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;
}

View File

@ -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"]);
});
});

View File

@ -0,0 +1,223 @@
type SettingMetadata<T, Input = T | null | undefined> = {
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<T>(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<K extends SettingsConversionKey>(
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<K extends SettingsConversionKey>(
key: K,
value: SettingsConversionInputMap[K],
): string | null {
const metadata = settingsConversionMetadata[key];
return metadata.serialize(value);
}

View File

@ -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<string>).sort()).toEqual([
"proj-1",
"proj-2",
]);
expect(vi.mocked(settingsRepo.setSetting)).toHaveBeenCalledWith(
"resumeProjects",
JSON.stringify(normalized),
);
});
});

View File

@ -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<void> {
await action.persist();
if (action.sideEffect) {
await action.sideEffect();
}
}
export async function applySettingsUpdates(
input: UpdateSettingsInput,
): Promise<SettingsUpdatePlan> {
const context: SettingsUpdateContext = { input };
const actions: SettingsUpdateAction[] = [];
const deferredSideEffects = new Set<DeferredSideEffect>();
const keys = Object.keys(input) as Array<keyof UpdateSettingsInput>;
for (const key of keys) {
const handler = settingsUpdateRegistry[key] as
| SettingUpdateHandler<typeof key>
| 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",
),
};
}

View File

@ -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";

View File

@ -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<void>;
sideEffect?: () => void | Promise<void>;
};
export type SettingsUpdateResult = {
actions: SettingsUpdateAction[];
deferredSideEffects: Set<DeferredSideEffect>;
};
export type SettingsUpdateContext = {
input: UpdateSettingsInput;
};
export type SettingUpdateHandler<K extends keyof UpdateSettingsInput> = (args: {
key: K;
value: UpdateSettingsInput[K];
context: SettingsUpdateContext;
}) => Promise<SettingsUpdateResult> | 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<T>(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<typeof settingsRepo.setSetting>[0],
value: string | null,
sideEffect?: () => void | Promise<void>,
): SettingsUpdateAction {
return {
settingKey,
persist: () => settingsRepo.setSetting(settingKey, value),
sideEffect,
};
}
function singleAction<K extends keyof UpdateSettingsInput>(
fn: SettingUpdateHandler<K>,
): SettingUpdateHandler<K> {
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<K>;
}> = {
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"],
}),
),
};

View File

@ -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<AppSettings> {
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,