From 54adc66e7331c3ebcae2f754caec8b4662ed70ae Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 5 Apr 2026 21:40:35 -0400 Subject: [PATCH] Score hybrid/onsite jobs low when job search profile is remote-only - Require workArrangementMatch in LLM schema; show badge in Fit Assessment - Prompt: remote-only vs hybrid/N days in office is a hard cap (~35 score) - Deterministic cap from JD keywords + isRemote/workFromHomeType when only Remote is selected - hasNonEmptyProfile includes preferredWorkArrangement and preferredLocations so prefs reach the scorer Made-with: Cursor --- .../src/client/components/FitAssessment.tsx | 30 ++++ orchestrator/src/server/services/scorer.ts | 161 +++++++++++++++++- shared/src/types/settings.ts | 2 + 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/orchestrator/src/client/components/FitAssessment.tsx b/orchestrator/src/client/components/FitAssessment.tsx index 1d9a697..5dd850f 100644 --- a/orchestrator/src/client/components/FitAssessment.tsx +++ b/orchestrator/src/client/components/FitAssessment.tsx @@ -54,6 +54,33 @@ function RoleMatchBadge({ score }: { score: number }) { ); } +function WorkArrangementBadge({ score }: { score: number }) { + const color = + score >= 70 + ? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20" + : score >= 40 + ? "text-amber-500 bg-amber-500/10 border-amber-500/20" + : "text-red-500 bg-red-500/10 border-red-500/20"; + + const label = + score >= 70 + ? "Strong remote / location fit" + : score >= 40 + ? "Partial location fit" + : "Weak location fit"; + + return ( + + {label} ({score}%) + + ); +} + export const FitAssessment: React.FC = ({ job, className, @@ -73,6 +100,9 @@ export const FitAssessment: React.FC = ({ Fit Assessment {analysis && } + {analysis && typeof analysis.workArrangementMatch === "number" && ( + + )} {job.suitabilityReason && (

diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index b5f2ade..a199915 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -40,6 +40,11 @@ const SCORING_SCHEMA: JsonSchemaDefinition = { description: "How well the job role type matches what the candidate wants (0-100)", }, + workArrangementMatch: { + type: "integer", + description: + "How well the job's work location arrangement matches the candidate's preferences: remote vs hybrid vs on-site (0-100). 100 = perfect match (e.g. fully remote job when candidate wants remote only).", + }, strengths: { type: "array", items: { type: "string" }, @@ -69,6 +74,7 @@ const SCORING_SCHEMA: JsonSchemaDefinition = { "score", "reason", "roleTypeMatch", + "workArrangementMatch", "strengths", "gaps", "suggestions", @@ -82,6 +88,7 @@ interface ScoringLlmResponse { score: number; reason: string; roleTypeMatch?: number; + workArrangementMatch?: number; strengths?: string[]; gaps?: string[]; suggestions?: string[]; @@ -137,6 +144,10 @@ function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null { typeof data.roleTypeMatch === "number" ? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch))) : 50, + workArrangementMatch: + typeof data.workArrangementMatch === "number" + ? Math.min(100, Math.max(0, Math.round(data.workArrangementMatch))) + : undefined, strengths: Array.isArray(data.strengths) ? data.strengths : [], gaps: Array.isArray(data.gaps) ? data.gaps : [], suggestions: Array.isArray(data.suggestions) ? data.suggestions : [], @@ -215,6 +226,16 @@ export async function scoreJobSuitability( clampedScore = roleTypeMatchCap.score; clampedReason = roleTypeMatchCap.reason; + const remoteCap = applyRemoteOfficeMismatchCap( + job, + clampedScore, + clampedReason, + data, + hasProfile ? jobSearchProfile : null, + ); + clampedScore = remoteCap.score; + clampedReason = remoteCap.reason; + const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, { penalizeMissingSalary: settings.penalizeMissingSalary.value, missingSalaryPenalty: settings.missingSalaryPenalty.value, @@ -268,11 +289,127 @@ function applyRoleTypeMatchCap( return { score: cappedScore, reason: `${reason} ${capNote}` }; } +const REMOTE_ONLY_OFFICE_MISMATCH_CEILING = 35; +const WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD = 40; + +/** + * Candidate selected "Remote" in job search profile but not "Hybrid" or "On-site". + */ +function candidateWantsRemoteOnly(p: JobSearchProfile): boolean { + const wa = p.preferredWorkArrangement.map((s) => s.trim().toLowerCase()); + if (!wa.includes("remote")) return false; + if (wa.includes("hybrid") || wa.includes("onsite")) return false; + return true; +} + +/** + * Job text / metadata suggests hybrid or mandatory office presence (not remote-only). + */ +function jobSignalsHybridOrOnsite(job: Job): boolean { + const blob = [ + job.title, + job.jobDescription ?? "", + job.location ?? "", + job.workFromHomeType ?? "", + job.jobType ?? "", + ] + .filter(Boolean) + .join("\n") + .toLowerCase(); + + const strongRemoteOnly = + /\b100%\s*remote\b|\bfully\s+remote\b|\bremote[\s-]only\b|\bcompletely\s+remote\b|\bwork\s+from\s+anywhere\b|\banywhere\s+in\s+the\s+(us|usa|uk|world)\b/.test( + blob, + ); + + const hybridOrOffice = + /\bhybrid\b/.test(blob) || + /\bremote[\s-]?hybrid\b/.test(blob) || + /\bhybrid[\s-]?remote\b/.test(blob) || + /\b\d[\d]?\s+days?\s+(a|per)\s+week\b.*\b(in[\s-]?office|on[\s-]?site|onsite|at\s+the\s+office)\b/.test( + blob, + ) || + /\b(in[\s-]?office|on[\s-]?site|onsite|at\s+the\s+office)\b.*\b\d[\d]?\s+days?\b/.test( + blob, + ) || + /\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+days?\b.*\b(in[\s-]?office|on[\s-]?site|onsite)\b/.test( + blob, + ) || + /\b(in[\s-]?office|on[\s-]?site|onsite)\b.*\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+days?\b/.test( + blob, + ) || + /\boffice[\s-]based\b/.test(blob) || + /\bon[\s-]?site\s+(role|position|required|mandatory)\b/.test(blob) || + /\b(required|must)\b.*\b(in[\s-]?office|on[\s-]?site|onsite|in[\s-]?person)\b/.test( + blob, + ); + + const wfh = (job.workFromHomeType ?? "").toLowerCase(); + if (wfh.includes("hybrid")) return true; + + if (job.isRemote === false) { + if (strongRemoteOnly && !hybridOrOffice) return false; + return true; + } + + if (hybridOrOffice) return true; + + return false; +} + +/** + * Cap score when candidate wants remote-only but the job is hybrid / on-site, or + * when the model admits a poor work-arrangement fit but still scores high. + */ +function applyRemoteOfficeMismatchCap( + job: Job, + score: number, + reason: string, + data: ScoringLlmResponse, + jobSearchProfile: JobSearchProfile | null, +): { score: number; reason: string } { + if (!jobSearchProfile || !candidateWantsRemoteOnly(jobSearchProfile)) { + return { score, reason }; + } + + const officeLikely = jobSignalsHybridOrOnsite(job); + const wam = + typeof data.workArrangementMatch === "number" + ? data.workArrangementMatch + : null; + const llmSaysPoorArrangement = + wam !== null && wam < WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD; + + if (!officeLikely && !llmSaysPoorArrangement) { + return { score, reason }; + } + + if (score <= REMOTE_ONLY_OFFICE_MISMATCH_CEILING) { + return { score, reason }; + } + + const cappedScore = Math.min(score, REMOTE_ONLY_OFFICE_MISMATCH_CEILING); + const why = officeLikely + ? "job appears hybrid or on-site" + : `work arrangement match ${wam}% < ${WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD}%`; + const capNote = `Capped from ${score} to ${cappedScore} (remote-only preference vs ${why}).`; + logger.info("Applied remote-only / office mismatch cap", { + jobId: job.id, + originalScore: score, + cappedScore, + officeLikely, + workArrangementMatch: wam, + }); + return { score: cappedScore, reason: `${reason} ${capNote}` }; +} + function hasNonEmptyProfile(p: JobSearchProfile): boolean { return ( p.targetRoles.length > 0 || p.mustHaveSkills.length > 0 || p.dealBreakers.length > 0 || + p.preferredWorkArrangement.length > 0 || + p.preferredLocations.length > 0 || p.aboutMe.trim().length > 0 || p.experienceLevel.trim().length > 0 ); @@ -409,7 +546,14 @@ DEAL-BREAKER RULES (STRICTLY ENFORCE — these override all other criteria): - A job mentioning a candidate's skill as a minor "nice-to-have" does NOT make it a good match if the core role is completely different from what the candidate wants. - When in doubt about role type, err on the side of a LOWER score. The candidate would rather miss - a borderline match than waste time on roles that don't align with their career focus.` + a borderline match than waste time on roles that don't align with their career focus. +- WORK ARRANGEMENT (read the full job description — do not trust a vague "Remote" tag alone): + * If Preferred Work Arrangement is ONLY "Remote" (no Hybrid / On-site selected): any hybrid role, + fixed days in office, or mandatory on-site requirement MUST score 0-35 overall and workArrangementMatch 0-25. + Examples: "3 days a week in office", "hybrid", "on-site 2 days", "in-person collaboration required". + * If the candidate also selected Hybrid or On-site, do not treat hybrid as a hard mismatch. + * Fully remote / work-from-anywhere jobs when candidate wants remote: workArrangementMatch 90-100. + * Mention hybrid/on-site mismatch in dealBreakerHits when the candidate is remote-only.` : ""; const scoringCriteria = hasProfilePrefs @@ -417,11 +561,12 @@ DEAL-BREAKER RULES (STRICTLY ENFORCE — these override all other criteria): - Role type alignment with target roles: 0-40 points (GATING FACTOR — if this is below 15, the total score MUST be below 25 regardless of other criteria) - Skills match with role-relevant skills (must-haves weighted 3x, nice-to-haves 1x): 0-25 points - Experience level match: 0-15 points -- Location/remote work alignment with preferences: 0-10 points +- Location / remote vs hybrid / on-site alignment with preferences: 0-15 points (GATING when candidate is remote-only and job requires office days) - Industry/domain fit: 0-5 points - Career growth and salary alignment: 0-5 points -CRITICAL: A "Senior Software Engineer" role and a "QA Automation Engineer" role are FUNDAMENTALLY DIFFERENT job types even if they share programming languages. Evaluate the PRIMARY function of the role, not just keyword overlap.` +CRITICAL: A "Senior Software Engineer" role and a "QA Automation Engineer" role are FUNDAMENTALLY DIFFERENT job types even if they share programming languages. Evaluate the PRIMARY function of the role, not just keyword overlap. +CRITICAL: If the candidate wants REMOTE ONLY and the posting requires any regular office presence, the TOTAL score cannot exceed ~35 regardless of skills match.` : `SCORING CRITERIA: - Skills match (technologies, frameworks, languages): 0-30 points - Experience level match: 0-25 points @@ -459,10 +604,11 @@ ${ IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON. REQUIRED FORMAT (exactly this structure): -{"score": , "reason": "<1-2 sentence explanation>", "roleTypeMatch": , "strengths": ["", ""], "gaps": [""], "suggestions": [""], "dealBreakerHits": []} +{"score": , "reason": "<1-2 sentence explanation>", "roleTypeMatch": , "workArrangementMatch": , "strengths": ["", ""], "gaps": [""], "suggestions": [""], "dealBreakerHits": []} RULES FOR ANALYSIS FIELDS: - "roleTypeMatch": How well does this job's role TYPE match what the candidate wants? 100 = perfect role type, 0 = completely wrong type of work. +- "workArrangementMatch": How well do location and work arrangement match? E.g. remote-only candidate + fully remote job = 95-100; remote-only + hybrid or N days in office = 0-25. - "strengths": 2-4 specific things where the candidate is a strong match. Be concrete (e.g. "Has 2 years React experience matching the 1+ year requirement"). - "gaps": 1-3 specific skills/requirements the candidate lacks. Be honest and specific. - "suggestions": 1-3 actionable things the candidate could do to be stronger for this type of role. @@ -471,10 +617,13 @@ RULES FOR ANALYSIS FIELDS: EXAMPLE RESPONSES: Role mismatch (candidate wants QA/SDET, job is core software engineering): -{"score": 15, "reason": "This is a core software engineering role focused on GPU infrastructure and platform development. Despite shared languages (C#, Java, Python), the primary responsibilities are software development, not testing or quality assurance.", "roleTypeMatch": 10, "strengths": ["Experience with C# and Java", "Familiar with Azure cloud"], "gaps": ["Role is software development, not QA/SDET", "No GPU/HPC infrastructure experience", "No hardware/software interaction experience"], "suggestions": ["Focus on SDET or QA Automation roles at Microsoft instead", "Look for test infrastructure roles in cloud platform teams"], "dealBreakerHits": ["Role type mismatch: Senior Software Engineer (development) vs target of QA Automation/SDET"]} +{"score": 15, "reason": "This is a core software engineering role focused on GPU infrastructure and platform development. Despite shared languages (C#, Java, Python), the primary responsibilities are software development, not testing or quality assurance.", "roleTypeMatch": 10, "workArrangementMatch": 70, "strengths": ["Experience with C# and Java", "Familiar with Azure cloud"], "gaps": ["Role is software development, not QA/SDET", "No GPU/HPC infrastructure experience", "No hardware/software interaction experience"], "suggestions": ["Focus on SDET or QA Automation roles at Microsoft instead", "Look for test infrastructure roles in cloud platform teams"], "dealBreakerHits": ["Role type mismatch: Senior Software Engineer (development) vs target of QA Automation/SDET"]} + +Remote-only candidate, hybrid job (skills match but office days required): +{"score": 28, "reason": "Strong technical fit but the role is hybrid with multiple days per week in the office; candidate preferences indicate remote-only.", "roleTypeMatch": 85, "workArrangementMatch": 15, "strengths": ["Stack aligns with automation and CI experience"], "gaps": ["Hybrid / in-office requirement conflicts with remote-only preference"], "suggestions": ["Filter for fully remote listings or select Hybrid in job search preferences if open to it"], "dealBreakerHits": ["Work arrangement: hybrid / in-office vs remote-only preference"]} Good match (role aligns with candidate's target): -{"score": 78, "reason": "Strong QA automation role with Playwright requirement matching the candidate's core expertise. CI/CD pipeline ownership aligns well with their DevOps experience.", "roleTypeMatch": 90, "strengths": ["5+ years Playwright experience exceeds the 2-year requirement", "Strong CI/CD pipeline experience with GitHub Actions"], "gaps": ["No experience with the company's specific domain"], "suggestions": ["Highlight regulated-industry QA experience from iGaming role"], "dealBreakerHits": []}`; +{"score": 78, "reason": "Strong QA automation role with Playwright requirement matching the candidate's core expertise. CI/CD pipeline ownership aligns well with their DevOps experience.", "roleTypeMatch": 90, "workArrangementMatch": 95, "strengths": ["5+ years Playwright experience exceeds the 2-year requirement", "Strong CI/CD pipeline experience with GitHub Actions"], "gaps": ["No experience with the company's specific domain"], "suggestions": ["Highlight regulated-industry QA experience from iGaming role"], "dealBreakerHits": []}`; } export function sanitizeProfileForPrompt( diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 751e564..0eaed4c 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -32,6 +32,8 @@ export interface UpdateSearchProfileInput { export interface SuitabilityAnalysis { roleTypeMatch: number; + /** How well location / remote vs hybrid / on-site matches preferences (0–100). */ + workArrangementMatch?: number; strengths: string[]; gaps: string[]; suggestions: string[];