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
This commit is contained in:
ilia 2026-04-05 21:40:35 -04:00
parent 14a6da4bdf
commit 54adc66e73
3 changed files with 187 additions and 6 deletions

View File

@ -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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium",
color,
)}
>
{label} ({score}%)
</span>
);
}
export const FitAssessment: React.FC<FitAssessmentProps> = ({
job,
className,
@ -73,6 +100,9 @@ export const FitAssessment: React.FC<FitAssessmentProps> = ({
<Sparkles className="h-3 w-3" />
Fit Assessment
{analysis && <RoleMatchBadge score={analysis.roleTypeMatch} />}
{analysis && typeof analysis.workArrangementMatch === "number" && (
<WorkArrangementBadge score={analysis.workArrangementMatch} />
)}
</div>
{job.suitabilityReason && (
<p className="text-xs text-foreground/90 leading-relaxed font-medium">

View File

@ -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": <integer 0-100>, "reason": "<1-2 sentence explanation>", "roleTypeMatch": <integer 0-100>, "strengths": ["<strength 1>", "<strength 2>"], "gaps": ["<gap 1>"], "suggestions": ["<suggestion 1>"], "dealBreakerHits": []}
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>", "roleTypeMatch": <integer 0-100>, "workArrangementMatch": <integer 0-100>, "strengths": ["<strength 1>", "<strength 2>"], "gaps": ["<gap 1>"], "suggestions": ["<suggestion 1>"], "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(

View File

@ -32,6 +32,8 @@ export interface UpdateSearchProfileInput {
export interface SuitabilityAnalysis {
roleTypeMatch: number;
/** How well location / remote vs hybrid / on-site matches preferences (0100). */
workArrangementMatch?: number;
strengths: string[];
gaps: string[];
suggestions: string[];