diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 52dc098..c4e1cf6 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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 = () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 142f576..63f2c17 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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} /> + = ({ )} + {job.salary?.trim() && ( +
+ {job.salary} +
+ )} {/* Single triage cue: score only (status shown via dot) */} diff --git a/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx new file mode 100644 index 0000000..8cc8000 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx @@ -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 = ({ + values, + isLoading, + isSaving, +}) => { + const { penalizeMissingSalary, missingSalaryPenalty } = values; + const { control, watch } = useFormContext(); + + // Watch the current form value to conditionally show/hide penalty input + const currentPenalizeEnabled = + watch("penalizeMissingSalary") ?? penalizeMissingSalary.default; + + return ( + + + Scoring Settings + + +
+ {/* Enable penalty toggle */} +
+ ( + { + field.onChange( + checked === "indeterminate" ? null : checked === true, + ); + }} + disabled={isLoading || isSaving} + /> + )} + /> +
+ +

+ Reduce suitability scores for jobs that do not include salary + information. Jobs with any salary text (including "Competitive") + are not penalized. +

+
+
+ + {/* Penalty amount input - only shown when enabled */} + {currentPenalizeEnabled && ( +
+ ( + { + 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}`} + /> + )} + /> +
+ )} + + + + {/* Effective/Default values display */} +
+
+
+ Penalty Enabled +
+
+ Effective: {penalizeMissingSalary.effective ? "Yes" : "No"} | + Default: {penalizeMissingSalary.default ? "Yes" : "No"} +
+
+
+
+ Penalty Amount +
+
+ Effective: {missingSalaryPenalty.effective} | Default:{" "} + {missingSalaryPenalty.default} +
+
+
+
+
+
+ ); +}; diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index d1f278d..3892143 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -48,3 +48,8 @@ export type BackupValues = { backupHour: EffectiveDefault; backupMaxCount: EffectiveDefault; }; + +export type ScoringValues = { + penalizeMissingSalary: EffectiveDefault; + missingSalaryPenalty: EffectiveDefault; +}; diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index d629f05..69eb429 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -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); + }); }); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 8922e2f..4d789d0 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -40,7 +40,9 @@ export type SettingKey = | "webhookSecret" | "backupEnabled" | "backupHour" - | "backupMaxCount"; + | "backupMaxCount" + | "penalizeMissingSalary" + | "missingSalaryPenalty"; export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)); diff --git a/orchestrator/src/server/services/modelSelection.test.ts b/orchestrator/src/server/services/modelSelection.test.ts index 6206445..05673ee 100644 --- a/orchestrator/src/server/services/modelSelection.test.ts +++ b/orchestrator/src/server/services/modelSelection.test.ts @@ -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, diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts index c4745cf..60fc9f8 100644 --- a/orchestrator/src/server/services/scorer.test.ts +++ b/orchestrator/src/server/services/scorer.test.ts @@ -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 { + 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; + let getSettingMock: ReturnType; + + beforeEach(async () => { + // Mock the settings module + const settingsModule = await import("./settings"); + getEffectiveSettingsMock = vi.fn() as unknown as ReturnType; + 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, + ); + }); + + 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"); + }); + }); +}); diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 53ef17b..3550d41 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -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, ): Promise { - 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 { // 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, }; } diff --git a/orchestrator/src/server/services/settings-conversion.test.ts b/orchestrator/src/server/services/settings-conversion.test.ts index 3499021..8df47ef 100644 --- a/orchestrator/src/server/services/settings-conversion.test.ts +++ b/orchestrator/src/server/services/settings-conversion.test.ts @@ -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); + }); }); diff --git a/orchestrator/src/server/services/settings-conversion.ts b/orchestrator/src/server/services/settings-conversion.ts index 8360420..f248fe5 100644 --- a/orchestrator/src/server/services/settings-conversion.ts +++ b/orchestrator/src/server/services/settings-conversion.ts @@ -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( diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts index b7d3edb..258c756 100644 --- a/orchestrator/src/server/services/settings-update/registry.ts +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -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)], + }), + ), }; diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index fd73f18..c34732c 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -205,6 +205,25 @@ export async function getEffectiveSettings(): Promise { 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 { backupMaxCount, defaultBackupMaxCount, overrideBackupMaxCount, + penalizeMissingSalary, + defaultPenalizeMissingSalary, + overridePenalizeMissingSalary, + missingSalaryPenalty, + defaultMissingSalaryPenalty, + overrideMissingSalaryPenalty, } as AppSettings; } diff --git a/package-lock.json b/package-lock.json index 8be6d16..9d19e3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/shared/src/settings-schema.ts b/shared/src/settings-schema.ts index 43fb387..4e84493 100644 --- a/shared/src/settings-schema.ts +++ b/shared/src/settings-schema.ts @@ -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) { diff --git a/shared/src/types.ts b/shared/src/types.ts index 0b37e6c..1c84498 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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 {