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":