feat: add salary display and missing salary penalty scoring (#86)

This commit is contained in:
Devin Collins 2026-02-04 23:35:17 -08:00 committed by GitHub
parent 16a8f1d15a
commit d18464548e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 796 additions and 10 deletions

View File

@ -124,6 +124,13 @@ const baseSettings: AppSettings = {
backupMaxCount: 5,
defaultBackupMaxCount: 5,
overrideBackupMaxCount: null,
// Scoring settings
penalizeMissingSalary: false,
defaultPenalizeMissingSalary: false,
overridePenalizeMissingSalary: null,
missingSalaryPenalty: 10,
defaultMissingSalaryPenalty: 10,
overrideMissingSalaryPenalty: null,
};
const renderPage = () => {

View File

@ -8,6 +8,7 @@ import { GradcrackerSection } from "@client/pages/settings/components/Gradcracke
import { JobspySection } from "@client/pages/settings/components/JobspySection";
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
@ -72,6 +73,8 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
backupEnabled: null,
backupHour: null,
backupMaxCount: null,
penalizeMissingSalary: null,
missingSalaryPenalty: null,
};
type LlmProviderValue = LlmProviderId | null;
@ -115,6 +118,8 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
backupEnabled: null,
backupHour: null,
backupMaxCount: null,
penalizeMissingSalary: null,
missingSalaryPenalty: null,
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
@ -152,6 +157,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
backupEnabled: data.overrideBackupEnabled,
backupHour: data.overrideBackupHour,
backupMaxCount: data.overrideBackupMaxCount,
penalizeMissingSalary: data.overridePenalizeMissingSalary,
missingSalaryPenalty: data.overrideMissingSalaryPenalty,
});
const normalizeString = (value: string | null | undefined) => {
@ -333,6 +340,16 @@ const getDerivedSettings = (settings: AppSettings | null) => {
default: settings?.defaultBackupMaxCount ?? 5,
},
},
scoring: {
penalizeMissingSalary: {
effective: settings?.penalizeMissingSalary ?? false,
default: settings?.defaultPenalizeMissingSalary ?? false,
},
missingSalaryPenalty: {
effective: settings?.missingSalaryPenalty ?? 10,
default: settings?.defaultMissingSalaryPenalty ?? 10,
},
},
};
};
@ -480,6 +497,7 @@ export const SettingsPage: React.FC = () => {
defaultResumeProjects,
profileProjects,
backup,
scoring,
} = derived;
// Backup functions
@ -691,6 +709,14 @@ export const SettingsPage: React.FC = () => {
data.backupMaxCount,
backup.backupMaxCount.default,
),
penalizeMissingSalary: nullIfSame(
data.penalizeMissingSalary,
scoring.penalizeMissingSalary.default,
),
missingSalaryPenalty: nullIfSame(
data.missingSalaryPenalty,
scoring.missingSalaryPenalty.default,
),
...envPayload,
};
@ -848,6 +874,11 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<ScoringSettingsSection
values={scoring}
isLoading={isLoading}
isSaving={isSaving}
/>
<EnvironmentSettingsSection
values={envSettings}
isLoading={isLoading}

View File

@ -87,6 +87,11 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
</span>
)}
</div>
{job.salary?.trim() && (
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.salary}
</div>
)}
</div>
{/* Single triage cue: score only (status shown via dot) */}

View File

@ -0,0 +1,134 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { ScoringValues } 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 { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type ScoringSettingsSectionProps = {
values: ScoringValues;
isLoading: boolean;
isSaving: boolean;
};
export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { penalizeMissingSalary, missingSalaryPenalty } = values;
const { control, watch } = useFormContext<UpdateSettingsInput>();
// Watch the current form value to conditionally show/hide penalty input
const currentPenalizeEnabled =
watch("penalizeMissingSalary") ?? penalizeMissingSalary.default;
return (
<AccordionItem value="scoring" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Scoring Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{/* Enable penalty toggle */}
<div className="flex items-start space-x-3">
<Controller
name="penalizeMissingSalary"
control={control}
render={({ field }) => (
<Checkbox
id="penalizeMissingSalary"
checked={field.value ?? penalizeMissingSalary.default}
onCheckedChange={(checked) => {
field.onChange(
checked === "indeterminate" ? null : checked === true,
);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="penalizeMissingSalary"
className="text-sm font-medium leading-none cursor-pointer"
>
Penalize Missing Salary
</label>
<p className="text-xs text-muted-foreground">
Reduce suitability scores for jobs that do not include salary
information. Jobs with any salary text (including "Competitive")
are not penalized.
</p>
</div>
</div>
{/* Penalty amount input - only shown when enabled */}
{currentPenalizeEnabled && (
<div className="pl-7">
<Controller
name="missingSalaryPenalty"
control={control}
render={({ field }) => (
<SettingsInput
label="Penalty Amount"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 0,
max: 100,
step: 1,
value: field.value ?? missingSalaryPenalty.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null);
} else {
field.onChange(Math.min(100, Math.max(0, value)));
}
},
}}
disabled={isLoading || isSaving}
helper={`Points to subtract from suitability score (0-100). Default: ${missingSalaryPenalty.default}.`}
current={`Effective: ${missingSalaryPenalty.effective} | Default: ${missingSalaryPenalty.default}`}
/>
)}
/>
</div>
)}
<Separator />
{/* Effective/Default values display */}
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">
Penalty Enabled
</div>
<div className="break-words font-mono text-xs">
Effective: {penalizeMissingSalary.effective ? "Yes" : "No"} |
Default: {penalizeMissingSalary.default ? "Yes" : "No"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Penalty Amount
</div>
<div className="break-words font-mono text-xs">
Effective: {missingSalaryPenalty.effective} | Default:{" "}
{missingSalaryPenalty.default}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -48,3 +48,8 @@ export type BackupValues = {
backupHour: EffectiveDefault<number>;
backupMaxCount: EffectiveDefault<number>;
};
export type ScoringValues = {
penalizeMissingSalary: EffectiveDefault<boolean>;
missingSalaryPenalty: EffectiveDefault<number>;
};

View File

@ -73,4 +73,51 @@ describe.sequential("Settings API routes", () => {
expect(body.ok).toBe(false);
expect(body.error.message).toContain("Username is required");
});
it("handles salary penalty settings with validation", async () => {
// Get initial settings
const initialRes = await fetch(`${baseUrl}/api/settings`);
const initialBody = await initialRes.json();
expect(initialBody.ok).toBe(true);
expect(initialBody.data.penalizeMissingSalary).toBe(false);
expect(initialBody.data.missingSalaryPenalty).toBe(10);
// Test invalid penalty values
const invalidRes = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missingSalaryPenalty: 150 }),
});
expect(invalidRes.status).toBe(400);
const negativeRes = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missingSalaryPenalty: -10 }),
});
expect(negativeRes.status).toBe(400);
// Test valid settings update
const validRes = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
penalizeMissingSalary: true,
missingSalaryPenalty: 20,
}),
});
const validBody = await validRes.json();
expect(validBody.ok).toBe(true);
expect(validBody.data.penalizeMissingSalary).toBe(true);
expect(validBody.data.overridePenalizeMissingSalary).toBe(true);
expect(validBody.data.missingSalaryPenalty).toBe(20);
expect(validBody.data.overrideMissingSalaryPenalty).toBe(20);
// Verify persistence
const getRes = await fetch(`${baseUrl}/api/settings`);
const getBody = await getRes.json();
expect(getBody.ok).toBe(true);
expect(getBody.data.penalizeMissingSalary).toBe(true);
expect(getBody.data.missingSalaryPenalty).toBe(20);
});
});

View File

@ -40,7 +40,9 @@ export type SettingKey =
| "webhookSecret"
| "backupEnabled"
| "backupHour"
| "backupMaxCount";
| "backupMaxCount"
| "penalizeMissingSalary"
| "missingSalaryPenalty";
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key));

View File

@ -8,6 +8,7 @@ import { generateTailoring } from "./summary";
// Mock the settings repository
vi.mock("../repositories/settings", () => ({
getSetting: vi.fn(),
getAllSettings: vi.fn(),
}));
describe("Model Selection Logic", () => {
@ -22,6 +23,9 @@ describe("Model Selection Logic", () => {
MODEL: "env-model",
};
// Mock getAllSettings to return empty settings (no overrides)
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({});
// Mock global fetch to capture the request and return a dummy success response
global.fetch = vi.fn().mockResolvedValue({
ok: true,

View File

@ -2,7 +2,8 @@
* Tests for scorer.ts - focusing on robust JSON parsing from AI responses
*/
import { describe, expect, it } from "vitest";
import type { Job } from "@shared/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseJsonFromContent } from "./scorer";
describe("parseJsonFromContent", () => {
@ -252,3 +253,364 @@ This score reflects the candidate's technical capabilities while accounting for
});
});
});
// Helper to create minimal test job
function createTestJob(overrides: Partial<Job> = {}): Job {
return {
id: "test-job-1",
source: "gradcracker",
sourceJobId: "ext-1",
jobUrlDirect: null,
datePosted: null,
title: "Software Engineer",
employer: "Test Company",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: "A test job",
status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: new Date().toISOString(),
processedAt: null,
appliedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("salary penalty", () => {
let getEffectiveSettingsMock: ReturnType<typeof vi.fn>;
let getSettingMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
// Mock the settings module
const settingsModule = await import("./settings");
getEffectiveSettingsMock = vi.fn() as unknown as ReturnType<typeof vi.fn>;
vi.spyOn(settingsModule, "getEffectiveSettings").mockImplementation(
getEffectiveSettingsMock as () => Promise<
import("@shared/types").AppSettings
>,
);
// Mock the settings repository
const settingsRepo = await import("../repositories/settings");
getSettingMock = vi.fn().mockResolvedValue(null) as unknown as ReturnType<
typeof vi.fn
>;
vi.spyOn(settingsRepo, "getSetting").mockImplementation(
getSettingMock as (
key: import("../repositories/settings").SettingKey,
) => Promise<string | null>,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("isSalaryMissing detection", () => {
it("should detect null salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70); // 80 - 10
expect(result.reason).toContain(
"Score reduced by 10 points due to missing salary information",
);
});
it("should detect empty string salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "" });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70);
expect(result.reason).toContain("missing salary information");
});
it("should detect whitespace-only salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: " " });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70);
expect(result.reason).toContain("missing salary information");
});
it("should NOT penalize jobs with non-empty salary", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "Competitive" });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty
expect(result.reason).not.toContain("missing salary");
});
it("should NOT penalize jobs with actual salary value", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "£40,000 - £50,000" });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty
expect(result.reason).not.toContain("missing salary");
});
});
describe("penalty application", () => {
it("should not apply penalty when disabled", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: false,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty when disabled
expect(result.reason).not.toContain("missing salary");
});
it("should clamp score to minimum 0 (high penalty on medium score)", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 100,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 50, reason: "Average match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(0); // Clamped, not negative
expect(result.reason).toContain("Score reduced by 100 points");
});
it("should clamp score to minimum 0 (low score with penalty)", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 5, reason: "Weak match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(0); // 5 - 10 = -5, clamped to 0
expect(result.reason).toContain("Score reduced by 10 points");
});
it("should handle penalty of 0", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 0,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No change with 0 penalty
expect(result.reason).toContain("Score reduced by 0 points");
});
it("should apply penalty with correct amount", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 25,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true,
data: { score: 90, reason: "Excellent match" },
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(65); // 90 - 25
expect(result.reason).toContain(
"Score reduced by 25 points due to missing salary information",
);
});
});
describe("mock scoring with penalty", () => {
it("should apply penalty in mock scoring fallback", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true,
missingSalaryPenalty: 10,
});
// Simulate API key error to trigger mock scoring
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: false,
error: "API key not configured",
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
// Mock score base is 50, with keyword bonuses from "Software Engineer"
// After 10 point penalty, should be reduced
expect(result.score).toBeLessThanOrEqual(50);
expect(result.reason).toContain("missing salary information");
});
it("should not apply penalty in mock scoring when disabled", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: false,
missingSalaryPenalty: 10,
});
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: false,
error: "API key not configured",
});
const job = createTestJob({ salary: null });
const result = await scoreJobSuitability(job, {});
expect(result.reason).not.toContain("missing salary");
});
});
});

View File

@ -6,6 +6,7 @@ import { logger } from "@infra/logger";
import type { Job } from "@shared/types";
import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
import { getEffectiveSettings } from "./settings";
interface SuitabilityResult {
score: number; // 0-100
@ -32,6 +33,47 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
},
};
/**
* Check if a job's salary field is missing/empty.
* Returns true for null, empty string, or whitespace-only strings.
*/
function isSalaryMissing(salary: string | null): boolean {
return salary === null || salary.trim() === "";
}
/**
* Apply salary penalty to a score if enabled.
* Returns the adjusted score, adjusted reason, and whether penalty was applied.
*/
function applySalaryPenalty(
job: Job,
originalScore: number,
originalReason: string,
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
): { score: number; reason: string; penaltyApplied: boolean } {
if (!settings.penalizeMissingSalary || !isSalaryMissing(job.salary)) {
return {
score: originalScore,
reason: originalReason,
penaltyApplied: false,
};
}
const penalty = settings.missingSalaryPenalty;
const adjustedScore = Math.max(0, originalScore - penalty);
const penaltyText = `Score reduced by ${penalty} points due to missing salary information.`;
const adjustedReason = `${originalReason} ${penaltyText}`;
logger.info("Applied salary penalty", {
jobId: job.id,
originalScore,
penalty,
finalScore: adjustedScore,
});
return { score: adjustedScore, reason: adjustedReason, penaltyApplied: true };
}
/**
* Score a job's suitability based on profile and job description.
* Includes retry logic for when AI returns garbage responses.
@ -40,9 +82,10 @@ export async function scoreJobSuitability(
job: Job,
profile: Record<string, unknown>,
): Promise<SuitabilityResult> {
const [overrideModel, overrideModelScorer] = await Promise.all([
const [overrideModel, overrideModelScorer, settings] = await Promise.all([
getSetting("model"),
getSetting("modelScorer"),
getEffectiveSettings(),
]);
// Precedence: Scorer-specific override > Global override > Env var > Default
const model =
@ -70,7 +113,7 @@ export async function scoreJobSuitability(
jobId: job.id,
error: result.error,
});
return mockScore(job);
return mockScore(job, settings);
}
const { score, reason } = result.data;
@ -80,12 +123,21 @@ export async function scoreJobSuitability(
logger.error("Invalid score in AI response, using mock scoring", {
jobId: job.id,
});
return mockScore(job);
return mockScore(job, settings);
}
const clampedScore = Math.min(100, Math.max(0, Math.round(score)));
const clampedReason = reason || "No explanation provided";
// Apply salary penalty if enabled
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
penalizeMissingSalary: settings.penalizeMissingSalary,
missingSalaryPenalty: settings.missingSalaryPenalty,
});
return {
score: Math.min(100, Math.max(0, Math.round(score))),
reason: reason || "No explanation provided",
score: penaltyResult.score,
reason: penaltyResult.reason,
};
}
@ -260,7 +312,10 @@ function sanitizeProfileForPrompt(
};
}
function mockScore(job: Job): SuitabilityResult {
async function mockScore(
job: Job,
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
): Promise<SuitabilityResult> {
// Simple keyword-based scoring as fallback
const jd = (job.jobDescription || "").toLowerCase();
const title = job.title.toLowerCase();
@ -299,9 +354,14 @@ function mockScore(job: Job): SuitabilityResult {
score = Math.min(100, Math.max(0, score));
const baseReason = "Scored using keyword matching (API key not configured)";
// Apply salary penalty if enabled
const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);
return {
score,
reason: "Scored using keyword matching (API key not configured)",
score: penaltyResult.score,
reason: penaltyResult.reason,
};
}

View File

@ -78,4 +78,56 @@ describe("settings-conversion", () => {
expect(malformedOverride.overrideValue).toBeNull();
expect(malformedOverride.value).toEqual(["web developer"]);
});
it("round-trips penalizeMissingSalary boolean setting", () => {
expect(serializeSettingValue("penalizeMissingSalary", true)).toBe("1");
expect(serializeSettingValue("penalizeMissingSalary", false)).toBe("0");
expect(resolveSettingValue("penalizeMissingSalary", "1").value).toBe(true);
expect(resolveSettingValue("penalizeMissingSalary", "0").value).toBe(false);
expect(resolveSettingValue("penalizeMissingSalary", "true").value).toBe(
true,
);
expect(resolveSettingValue("penalizeMissingSalary", undefined).value).toBe(
false,
);
});
it("round-trips missingSalaryPenalty numeric setting with clamping", () => {
const serialized = serializeSettingValue("missingSalaryPenalty", 10);
expect(serialized).toBe("10");
const resolved = resolveSettingValue(
"missingSalaryPenalty",
serialized ?? undefined,
);
expect(resolved.overrideValue).toBe(10);
expect(resolved.value).toBe(10);
expect(resolved.defaultValue).toBe(10);
// Test clamping
expect(resolveSettingValue("missingSalaryPenalty", "150").value).toBe(100);
expect(resolveSettingValue("missingSalaryPenalty", "-5").value).toBe(0);
expect(resolveSettingValue("missingSalaryPenalty", "0").value).toBe(0);
expect(resolveSettingValue("missingSalaryPenalty", "100").value).toBe(100);
});
it("respects environment variables for new salary settings", () => {
process.env.PENALIZE_MISSING_SALARY = "true";
process.env.MISSING_SALARY_PENALTY = "25";
const penalizeResolved = resolveSettingValue(
"penalizeMissingSalary",
undefined,
);
expect(penalizeResolved.defaultValue).toBe(true);
expect(penalizeResolved.value).toBe(true);
const penaltyResolved = resolveSettingValue(
"missingSalaryPenalty",
undefined,
);
expect(penaltyResolved.defaultValue).toBe(25);
expect(penaltyResolved.value).toBe(25);
});
});

View File

@ -20,6 +20,8 @@ type SettingsConversionValueMap = {
backupEnabled: boolean;
backupHour: number;
backupMaxCount: number;
penalizeMissingSalary: boolean;
missingSalaryPenalty: number;
};
type SettingsConversionInputMap = {
@ -193,6 +195,30 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
penalizeMissingSalary: {
defaultValue: () =>
(process.env.PENALIZE_MISSING_SALARY || "0") === "1" ||
(process.env.PENALIZE_MISSING_SALARY || "").toLowerCase() === "true",
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
missingSalaryPenalty: {
defaultValue: () => {
const raw = process.env.MISSING_SALARY_PENALTY;
if (!raw) return 10;
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) return 10;
return Math.min(100, Math.max(0, parsed));
},
parseOverride: (raw) => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(100, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
};
export function resolveSettingValue<K extends SettingsConversionKey>(

View File

@ -326,4 +326,14 @@ export const settingsUpdateRegistry: Partial<{
deferred: ["refreshBackupScheduler"],
}),
),
penalizeMissingSalary: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("penalizeMissingSalary", value)],
}),
),
missingSalaryPenalty: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("missingSalaryPenalty", value)],
}),
),
};

View File

@ -205,6 +205,25 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideBackupMaxCount = backupMaxCountSetting.overrideValue;
const backupMaxCount = backupMaxCountSetting.value;
const penalizeMissingSalarySetting = resolveSettingValue(
"penalizeMissingSalary",
overrides.penalizeMissingSalary,
);
const defaultPenalizeMissingSalary =
penalizeMissingSalarySetting.defaultValue;
const overridePenalizeMissingSalary =
penalizeMissingSalarySetting.overrideValue;
const penalizeMissingSalary = penalizeMissingSalarySetting.value;
const missingSalaryPenaltySetting = resolveSettingValue(
"missingSalaryPenalty",
overrides.missingSalaryPenalty,
);
const defaultMissingSalaryPenalty = missingSalaryPenaltySetting.defaultValue;
const overrideMissingSalaryPenalty =
missingSalaryPenaltySetting.overrideValue;
const missingSalaryPenalty = missingSalaryPenaltySetting.value;
return {
...envSettings,
model,
@ -272,6 +291,12 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
backupMaxCount,
defaultBackupMaxCount,
overrideBackupMaxCount,
penalizeMissingSalary,
defaultPenalizeMissingSalary,
overridePenalizeMissingSalary,
missingSalaryPenalty,
defaultMissingSalaryPenalty,
overrideMissingSalaryPenalty,
} as AppSettings;
}

1
package-lock.json generated
View File

@ -83,6 +83,7 @@
"license": "ISC",
"dependencies": {
"camoufox-js": "^0.8.0",
"job-ops-shared": "^1.0.0",
"playwright": "^1.57.0",
"tsx": "^4.4.0"
},

View File

@ -75,6 +75,14 @@ export const updateSettingsSchema = z
backupEnabled: z.boolean().nullable().optional(),
backupHour: z.number().int().min(0).max(23).nullable().optional(),
backupMaxCount: z.number().int().min(1).max(5).nullable().optional(),
penalizeMissingSalary: z.boolean().nullable().optional(),
missingSalaryPenalty: z
.number()
.int()
.min(0)
.max(100)
.nullable()
.optional(),
})
.superRefine((data, ctx) => {
if (data.enableBasicAuth) {

View File

@ -571,6 +571,13 @@ export interface AppSettings {
backupMaxCount: number;
defaultBackupMaxCount: number;
overrideBackupMaxCount: number | null;
// Scoring settings
penalizeMissingSalary: boolean;
defaultPenalizeMissingSalary: boolean;
overridePenalizeMissingSalary: boolean | null;
missingSalaryPenalty: number;
defaultMissingSalaryPenalty: number;
overrideMissingSalaryPenalty: number | null;
}
export interface BackupInfo {