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:
parent
14a6da4bdf
commit
54adc66e73
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user