feat: job notes, deal-breaker score cap, richer cover letters, bulk action limit bump

- Add per-listing notes (JobNotes component, notes column, auto-save)
- Enforce hard score cap (≤15) when deal-breaker hits are present;
  add clearance/citizenship deal-breaker rules to scoring prompt
- Cover letter prompt now uses full search profile (experience level,
  skills, work arrangement, location, salary, industries, deal-breakers)
  and produces longer, name-signed output
- Include candidate name + location in sanitized scorer profile
- Raise MAX_JOB_ACTION_BATCH_SIZE from 100 → 2500 (shared constant)
- Update README with new features

Made-with: Cursor
This commit is contained in:
ilia 2026-04-06 15:55:26 -04:00
parent 4d7c8ac0bc
commit 60b61ffe03
21 changed files with 333 additions and 57 deletions

View File

@ -20,11 +20,14 @@ Root `package.json` is an npm **workspace** root; day-to-day app commands usuall
## Features (high level)
- **Sources**: Multiple boards and aggregators (exact list evolves; see docs and extractor packages).
- **Scoring & tailoring**: LLM compares jobs to your resume profile; optional drafts for summary, headline, skills, and project selection.
- **Scoring & tailoring**: LLM compares jobs to your resume profile; optional drafts for summary, headline, skills, and project selection. The scorer enforces hard caps when deal-breakers fire (e.g. clearance/citizenship conflicts cap score to 0-15) and includes candidate name and location in the profile sent to the model.
- **Cover letters**: LLM-generated, profile-aware cover letters. The prompt uses the full job-search profile (experience level, target roles, must-have and nice-to-have skills, work arrangement, location, salary, industries, deal-breakers) and produces 4-5 paragraphs (~550 words) signed with the candidate's name.
- **Job notes**: Per-listing personal notes with debounced auto-save, available in Discovered, Ready, and detail panels.
- **PDFs**: Tailored exports via **Reactive Resume** (v4 or v5 API). Optional **local JSON resume** (`JOBOPS_LOCAL_RESUME_PATH` or Settings) as the base document for profile/tailoring; PDF export still uses RxResume when configured.
- **Pipeline**: Scheduled or manual runs (`POST /api/pipeline/run`, webhook trigger).
- **Post-application**: Optional Gmail-based inbox for interview/offer/rejection signals.
- **Job list filters** (orchestrator UI): Narrow the pipeline job list by **multiple sources** and **countries** (country is inferred from each listings location text). Filters sync to the URL (`source`, `sourceExclude`, `countries`, `countriesExclude`). Each source/country chip cycles **off → include → exclude** (exclude shows in red); listings marked **remote** still pass country include/exclude rules.
- **Bulk actions**: Select-all / batch actions support up to **2 500 jobs** per request (raised from 100).
- **Data**: SQLite and generated artifacts under `./data` (default in Docker).
## Requirements

View File

@ -0,0 +1,114 @@
import * as api from "@client/api";
import type { Job } from "@shared/types.js";
import { StickyNote } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
interface JobNotesProps {
job: Job;
onJobUpdated: () => void | Promise<void>;
className?: string;
}
const DEBOUNCE_MS = 800;
export const JobNotes: React.FC<JobNotesProps> = ({
job,
onJobUpdated,
className,
}) => {
const [value, setValue] = useState(job.notes ?? "");
const [isSaving, setIsSaving] = useState(false);
const pendingRef = useRef<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const jobIdRef = useRef(job.id);
useEffect(() => {
if (jobIdRef.current !== job.id) {
jobIdRef.current = job.id;
if (timerRef.current) clearTimeout(timerRef.current);
pendingRef.current = null;
}
setValue(job.notes ?? "");
}, [job.id, job.notes]);
const persist = useCallback(
async (text: string) => {
try {
setIsSaving(true);
await api.updateJob(job.id, { notes: text || null });
await onJobUpdated();
} catch (error) {
const msg =
error instanceof Error ? error.message : "Failed to save notes";
toast.error(msg);
} finally {
setIsSaving(false);
if (pendingRef.current !== null) {
const next = pendingRef.current;
pendingRef.current = null;
void persist(next);
}
}
},
[job.id, onJobUpdated],
);
const scheduleFlush = useCallback(
(text: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (isSaving) {
pendingRef.current = text;
} else {
void persist(text);
}
}, DEBOUNCE_MS);
},
[isSaving, persist],
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setValue(next);
scheduleFlush(next);
};
const handleBlur = () => {
if (timerRef.current) clearTimeout(timerRef.current);
const trimmed = value;
if (trimmed !== (job.notes ?? "")) {
if (isSaving) {
pendingRef.current = trimmed;
} else {
void persist(trimmed);
}
}
};
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex items-center gap-1.5">
<StickyNote className="h-3.5 w-3.5 text-muted-foreground/70" />
<span className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
Notes
</span>
{isSaving && (
<span className="text-[10px] text-muted-foreground/50 ml-auto">
Saving
</span>
)}
</div>
<Textarea
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Add notes about this listing…"
className="min-h-[72px] resize-y text-xs leading-relaxed bg-muted/5 border-border/40 placeholder:text-muted-foreground/40 focus-visible:ring-1"
/>
</div>
);
};

View File

@ -48,7 +48,7 @@ import {
import { useCoverLetterGeneration } from "../hooks/useCoverLetterGeneration";
import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { FitAssessment, JobHeader, JobNotes, TailoredSummary } from ".";
import { CoverLetterDisplay } from "./CoverLetterDisplay";
import { TailorMode } from "./discovered-panel/TailorMode";
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
@ -435,6 +435,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={job} />
<JobNotes job={job} onJobUpdated={onJobUpdated} />
{googleDorks.length > 0 ? (
<ReadySummaryAccordion

View File

@ -23,6 +23,7 @@ import {
CoverLetterDisplay,
FitAssessment,
JobHeader,
JobNotes,
TailoredSummary,
} from "..";
import { KbdHint } from "../KbdHint";
@ -41,6 +42,7 @@ interface DecideModeProps {
coverLetterGenerating: boolean;
onEditDetails: () => void;
onCheckSponsor?: () => Promise<void>;
onJobUpdated: () => void | Promise<void>;
}
export const DecideMode: React.FC<DecideModeProps> = ({
@ -54,6 +56,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
coverLetterGenerating,
onEditDetails,
onCheckSponsor,
onJobUpdated,
}) => {
const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl;
@ -113,6 +116,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={job} />
<JobNotes job={job} onJobUpdated={onJobUpdated} />
<CollapsibleSection
isOpen={showDescription}

View File

@ -144,6 +144,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onRequestCoverLetter={() => void generateForJob(job.id)}
coverLetterGenerating={coverLetterGenerating}
onEditDetails={() => setIsEditDetailsOpen(true)}
onJobUpdated={onJobUpdated}
onCheckSponsor={async () => {
try {
await api.checkSponsor(job.id);

View File

@ -2,6 +2,7 @@ export { CoverLetterDisplay } from "./CoverLetterDisplay";
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
export { FitAssessment } from "./FitAssessment";
export { JobHeader } from "./JobHeader";
export { JobNotes } from "./JobNotes";
export * from "./layout";
export { ManualImportSheet } from "./ManualImportSheet";
export { OpenJobListingButton } from "./OpenJobListingButton";

View File

@ -59,6 +59,7 @@ vi.mock("@client/components", () => ({
JobHeader: () => <div data-testid="job-header" />,
FitAssessment: () => <div data-testid="fit-assessment" />,
TailoredSummary: () => <div data-testid="tailored-summary" />,
JobNotes: () => <div data-testid="job-notes" />,
}));
vi.mock("@client/hooks/useSettings", () => ({

View File

@ -4,6 +4,7 @@ import {
DiscoveredPanel,
FitAssessment,
JobHeader,
JobNotes,
TailoredSummary,
} from "@client/components";
import { JobDetailsEditDrawer } from "@client/components/JobDetailsEditDrawer";
@ -624,6 +625,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={selectedJob} />
<JobNotes job={selectedJob} onJobUpdated={onJobUpdated} />
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div>

View File

@ -104,7 +104,7 @@ describe("useJobSelectionActions", () => {
});
it("caps select-all to the API max", () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
const activeJobs = Array.from({ length: 2501 }, (_, index) =>
createJob({ id: `job-${index + 1}`, status: "discovered" }),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
@ -120,11 +120,12 @@ describe("useJobSelectionActions", () => {
result.current.toggleSelectAll(true);
});
expect(result.current.selectedJobIds.size).toBe(100);
expect(result.current.selectedJobIds.size).toBe(2500);
expect(toast.error).toHaveBeenCalled();
});
it("does not send action requests above the max selection size", async () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
const activeJobs = Array.from({ length: 2501 }, (_, index) =>
createJob({ id: `job-${index + 1}`, status: "discovered" }),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
@ -137,10 +138,12 @@ describe("useJobSelectionActions", () => {
);
act(() => {
for (const job of activeJobs) {
result.current.toggleSelectJob(job.id);
}
result.current.toggleSelectAll(true);
});
act(() => {
result.current.toggleSelectJob("job-2501");
});
expect(result.current.selectedJobIds.size).toBe(2501);
await act(async () => {
await result.current.runJobAction("skip");

View File

@ -1,8 +1,9 @@
import * as api from "@client/api";
import type {
JobAction,
JobActionResponse,
JobListItem,
import {
type JobAction,
type JobActionResponse,
type JobListItem,
MAX_JOB_ACTION_BATCH_SIZE,
} from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -18,7 +19,7 @@ import {
} from "./jobActions";
import { clampNumber } from "./utils";
const MAX_JOB_ACTION_JOB_IDS = 100;
const MAX_JOB_ACTION_JOB_IDS = MAX_JOB_ACTION_BATCH_SIZE;
const jobActionLabel: Record<JobAction, string> = {
move_to_ready: "Moving jobs to Ready...",

View File

@ -709,7 +709,7 @@ describe.sequential("Jobs API routes", () => {
it("validates job action payloads", async () => {
const tooManyIds = Array.from(
{ length: 101 },
{ length: 2501 },
(_, index) => `job-${index}`,
);
const res = await fetch(`${baseUrl}/api/jobs/actions`, {

View File

@ -49,6 +49,7 @@ import {
type JobStatus,
type JobsListResponse,
type JobsRevisionResponse,
MAX_JOB_ACTION_BATCH_SIZE,
} from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
@ -175,6 +176,7 @@ const updateJobSchema = z.object({
tracerLinksEnabled: z.boolean().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(),
sponsorMatchNames: z.string().optional(),
notes: z.string().max(10000).nullable().optional(),
});
function isJobUrlConflictError(error: unknown): boolean {
@ -204,19 +206,19 @@ const updateOutcomeSchema = z.object({
const jobActionRequestSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("skip"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
jobIds: z.array(z.string().min(1)).min(1).max(MAX_JOB_ACTION_BATCH_SIZE),
}),
z.object({
action: z.literal("rescore"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
jobIds: z.array(z.string().min(1)).min(1).max(MAX_JOB_ACTION_BATCH_SIZE),
}),
z.object({
action: z.literal("generate_cover_letter"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
jobIds: z.array(z.string().min(1)).min(1).max(MAX_JOB_ACTION_BATCH_SIZE),
}),
z.object({
action: z.literal("move_to_ready"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
jobIds: z.array(z.string().min(1)).min(1).max(MAX_JOB_ACTION_BATCH_SIZE),
options: z
.object({
force: z.boolean().optional(),

View File

@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { getDataDir } from "../config/dataDir";
// Apply schema migrations before any query runs (same DB path as below).
import "./migrate";
import * as schema from "./schema";
// Database path - can be overridden via env for Docker

View File

@ -342,6 +342,9 @@ const migrations = [
// Add cover letter column for AI-generated cover letters
`ALTER TABLE jobs ADD COLUMN cover_letter TEXT`,
// User notes on listings (must exist before jobs→jobs_new rebuild copies row data)
`ALTER TABLE jobs ADD COLUMN notes TEXT`,
// Create search profiles table for multi-profile support
`CREATE TABLE IF NOT EXISTS search_profiles (
id TEXT PRIMARY KEY,
@ -461,6 +464,7 @@ const migrations = [
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
sponsor_match_score REAL,
sponsor_match_names TEXT,
notes TEXT,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT,
applied_at TEXT,
@ -475,7 +479,7 @@ const migrations = [
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
applied_at, created_at, updated_at
)
SELECT
@ -486,7 +490,7 @@ const migrations = [
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
applied_at, created_at, updated_at
FROM jobs`,
`DROP TABLE IF EXISTS jobs`,

View File

@ -105,6 +105,7 @@ export const jobs = sqliteTable("jobs", {
coverLetter: text("cover_letter"),
sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"),
notes: text("notes"),
// Timestamps
discoveredAt: text("discovered_at").notNull().default(sql`(datetime('now'))`),

View File

@ -561,6 +561,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null,
notes: row.notes ?? null,
jobType: row.jobType ?? null,
salarySource: row.salarySource ?? null,
salaryInterval: row.salaryInterval ?? null,

View File

@ -33,11 +33,22 @@ function buildCoverLetterPrompt(
const parts: string[] = [];
parts.push(
"Write a professional, compelling cover letter for this job application.",
"Write a polished, professional cover letter for this job application suitable for a senior candidate.",
);
parts.push("");
parts.push("RULES:");
parts.push("- Keep it concise: 3-4 paragraphs, under 400 words");
parts.push(
"- Length: 4-5 short paragraphs, maximum ~550 words; use clear paragraphs separated by line breaks",
);
parts.push(
"- Open with the role title and company name, then state relevant years of experience and primary domain (from resume/preferences)",
);
parts.push(
"- Tie 3-5 concrete points from the resume (employers, tools, scope, metrics if present) to requirements in the job description",
);
parts.push(
"- If CANDIDATE PREFERENCES include aboutMe, weave one sentence that reinforces motivation or focus (without repeating the whole resume)",
);
parts.push(
"- Lead with what makes the candidate a strong fit for THIS specific role",
);
@ -45,15 +56,22 @@ function buildCoverLetterPrompt(
"- Reference specific requirements from the job description and match them to candidate experience",
);
parts.push(
"- Be genuine and enthusiastic without being generic or sycophantic",
);
parts.push("- Use a professional but personable tone");
parts.push("- Do NOT include placeholder text like [Your Name] or [Date]");
parts.push(
"- Do NOT make up experience or skills the candidate doesn't have",
"- Be genuine and enthusiastic without being generic, overstated, or sycophantic",
);
parts.push(
"- End with a strong closing that expresses interest in next steps",
"- Use a professional tone appropriate for hiring managers and recruiters",
);
parts.push(
"- Sign with the candidate's full name from resume basics.name when present; otherwise use a neutral closing without brackets",
);
parts.push(
"- Do NOT include placeholder text like [Your Name], [Date], or similar",
);
parts.push(
"- Do NOT make up experience, employers, metrics, or skills the candidate doesn't have",
);
parts.push(
"- End with a concise closing thanking the reader and inviting next steps",
);
parts.push("");
@ -63,28 +81,64 @@ function buildCoverLetterPrompt(
if (job.location) parts.push(`Location: ${job.location}`);
if (job.jobDescription) {
const desc =
job.jobDescription.length > 3000
? `${job.jobDescription.slice(0, 3000)}...`
job.jobDescription.length > 4000
? `${job.jobDescription.slice(0, 4000)}...`
: job.jobDescription;
parts.push(`Description:\n${desc}`);
}
parts.push("");
parts.push("=== CANDIDATE RESUME ===");
parts.push("=== CANDIDATE RESUME (structured) ===");
parts.push(JSON.stringify(resumeProfile, null, 2));
if (searchProfile) {
parts.push("");
parts.push("=== CANDIDATE PREFERENCES ===");
if (searchProfile.aboutMe) {
parts.push(`About: ${searchProfile.aboutMe}`);
parts.push("=== CANDIDATE PREFERENCES (job search profile) ===");
if (searchProfile.aboutMe.trim()) {
parts.push(`About / narrative: ${searchProfile.aboutMe}`);
}
if (searchProfile.experienceLevel.trim()) {
parts.push(`Experience level: ${searchProfile.experienceLevel}`);
}
if (searchProfile.targetRoles.length > 0) {
parts.push(`Target roles: ${searchProfile.targetRoles.join(", ")}`);
}
if (searchProfile.mustHaveSkills.length > 0) {
parts.push(
`Key skills to highlight: ${searchProfile.mustHaveSkills.join(", ")}`,
`Must-have skills to emphasize: ${searchProfile.mustHaveSkills.join(", ")}`,
);
}
if (searchProfile.niceToHaveSkills.length > 0) {
parts.push(
`Supporting skills (mention if they appear in resume): ${searchProfile.niceToHaveSkills.join(", ")}`,
);
}
if (searchProfile.preferredWorkArrangement.length > 0) {
parts.push(
`Work arrangement preferences: ${searchProfile.preferredWorkArrangement.join(", ")}`,
);
}
if (searchProfile.preferredLocations.length > 0) {
parts.push(
`Location preferences: ${searchProfile.preferredLocations.join(", ")}`,
);
}
if (searchProfile.minimumSalary.trim()) {
parts.push(`Salary expectation context: ${searchProfile.minimumSalary}`);
}
if (searchProfile.industriesToTarget.length > 0) {
parts.push(
`Industries of interest: ${searchProfile.industriesToTarget.join(", ")}`,
);
}
if (searchProfile.industriesToAvoid.length > 0) {
parts.push(
`Do not position the candidate as seeking: ${searchProfile.industriesToAvoid.join(", ")}`,
);
}
if (searchProfile.dealBreakers.length > 0) {
parts.push(
`Candidate deal-breakers (do NOT argue against these or claim fit where the job clearly violates them): ${searchProfile.dealBreakers.join(", ")}`,
);
}
}
@ -101,7 +155,10 @@ export async function generateCoverLetter(
const prompt = buildCoverLetterPrompt(
job,
sanitizeProfileForPrompt(resumeProfile),
sanitizeProfileForPrompt(resumeProfile, {
maxExperienceItems: 8,
maxProjectItems: 8,
}),
searchProfile,
);

View File

@ -67,7 +67,7 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
type: "array",
items: { type: "string" },
description:
"Any deal-breakers from the candidate profile that this job triggers",
"Deal-breakers from the candidate profile or hard disqualifiers in the job (e.g. U.S.-only citizenship, clearance) that this job triggers. If this array is non-empty, score MUST be 0-15.",
},
},
required: [
@ -137,23 +137,38 @@ function applySalaryPenalty(
}
function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null {
if (!data.strengths && !data.gaps && !data.suggestions) return null;
const strengths = Array.isArray(data.strengths) ? data.strengths : [];
const gaps = Array.isArray(data.gaps) ? data.gaps : [];
const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
const dealBreakerHits = Array.isArray(data.dealBreakerHits)
? data.dealBreakerHits
: [];
const hasRoleType = typeof data.roleTypeMatch === "number";
const hasWorkArrangement = typeof data.workArrangementMatch === "number";
const hasAny =
strengths.length > 0 ||
gaps.length > 0 ||
suggestions.length > 0 ||
dealBreakerHits.length > 0 ||
hasRoleType ||
hasWorkArrangement;
if (!hasAny) return null;
return {
roleTypeMatch:
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 : [],
dealBreakerHits: Array.isArray(data.dealBreakerHits)
? data.dealBreakerHits
: [],
roleTypeMatch: hasRoleType
? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch as number)))
: 50,
workArrangementMatch: hasWorkArrangement
? Math.min(
100,
Math.max(0, Math.round(data.workArrangementMatch as number)),
)
: undefined,
strengths,
gaps,
suggestions,
dealBreakerHits,
};
}
@ -236,6 +251,15 @@ export async function scoreJobSuitability(
clampedScore = remoteCap.score;
clampedReason = remoteCap.reason;
const dealBreakerCap = applyDealBreakerHitsCap(
job.id,
clampedScore,
clampedReason,
analysis,
);
clampedScore = dealBreakerCap.score;
clampedReason = dealBreakerCap.reason;
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty.value,
@ -403,6 +427,31 @@ function applyRemoteOfficeMismatchCap(
return { score: cappedScore, reason: `${reason} ${capNote}` };
}
/** When the model lists deal-breakers but still returns a high score, enforce the same ceiling as the prompt. */
const DEAL_BREAKER_HIT_SCORE_CEILING = 15;
function applyDealBreakerHitsCap(
jobId: string,
score: number,
reason: string,
analysis: SuitabilityAnalysis | null,
): { score: number; reason: string } {
const hits = analysis?.dealBreakerHits ?? [];
if (hits.length === 0 || score <= DEAL_BREAKER_HIT_SCORE_CEILING) {
return { score, reason };
}
const cappedScore = Math.min(score, DEAL_BREAKER_HIT_SCORE_CEILING);
const capNote = `Capped from ${score} to ${cappedScore} (deal-breakers: ${hits.join("; ")}).`;
logger.info("Applied deal-breaker score cap", {
jobId,
originalScore: score,
cappedScore,
dealBreakerHits: hits,
});
return { score: cappedScore, reason: `${reason} ${capNote}` };
}
function hasNonEmptyProfile(p: JobSearchProfile): boolean {
return (
p.targetRoles.length > 0 ||
@ -542,6 +591,12 @@ DEAL-BREAKER RULES (STRICTLY ENFORCE — these override all other criteria):
* Candidate targets "QA Automation / SDET" "Software Engineer in Test" = roleTypeMatch 85-95
* Candidate targets "QA Automation / SDET" "Test Automation Engineer" = roleTypeMatch 85-95
- If any deal-breaker keywords appear in the job title or core requirements, score MUST be 0-15.
- Clearance / citizenship (hard gates not the same as "authorized to work in the U.S."):
* If the role requires U.S. citizenship only (e.g. "U.S. Citizenship required", "must be a U.S. citizen", "not available to visa holders"),
or active government clearance the job states as required (Secret, Top Secret, TS/SCI, "full scope poly", SCI, SAP),
and that conflicts with the candidate's profile, visa situation, or job-search deal-breakers, score MUST be 0-15
and explain in dealBreakerHits. Do NOT assign a high score for strong skill overlap when the candidate cannot be hired.
- If dealBreakerHits is non-empty, score MUST be 0-15 (never above 15).
- If the job requires experience far beyond the candidate's level, reduce score by 30-50 points.
- 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.
@ -612,7 +667,7 @@ RULES FOR ANALYSIS FIELDS:
- "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.
- "dealBreakerHits": List any deal-breakers triggered. Empty array if none.
- "dealBreakerHits": List any deal-breakers triggered. Empty array if none. If non-empty, score MUST be 0-15.
EXAMPLE RESPONSES:
@ -623,12 +678,19 @@ 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, "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": []}`;
{"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": []}
U.S. citizenship or TS/SCI required technical skills match but candidate cannot meet eligibility:
{"score": 12, "reason": "Despite alignment on automation and testing stack, the posting requires U.S. citizenship (or TS/SCI eligibility) that disqualifies a non-citizen or uncleared candidate regardless of skills.", "roleTypeMatch": 88, "workArrangementMatch": 80, "strengths": ["Strong test automation and CI/CD background"], "gaps": ["Mandatory U.S. citizenship or active TS/SCI not satisfied"], "suggestions": ["Focus on commercial-sector SDET roles without citizenship-only requirements"], "dealBreakerHits": ["Eligibility: U.S. citizenship / TS/SCI required"]}`;
}
export function sanitizeProfileForPrompt(
profile: Record<string, unknown>,
options?: { maxExperienceItems?: number; maxProjectItems?: number },
): Record<string, unknown> {
const maxExperienceItems = options?.maxExperienceItems ?? 5;
const maxProjectItems = options?.maxProjectItems ?? 6;
const p = profile as {
basics?: Record<string, unknown>;
sections?: {
@ -640,17 +702,27 @@ export function sanitizeProfileForPrompt(
};
const experienceItems = Array.isArray(p.sections?.experience?.items)
? p.sections?.experience?.items.slice(0, 5)
? p.sections?.experience?.items.slice(0, maxExperienceItems)
: [];
const projectItems = Array.isArray(p.sections?.projects?.items)
? p.sections?.projects?.items.slice(0, 6)
? p.sections?.projects?.items.slice(0, maxProjectItems)
: [];
const basics = p.basics as Record<string, unknown> | undefined;
const loc = basics?.location as Record<string, unknown> | undefined;
return {
basics: {
name: basics?.name ?? null,
headline: basics?.headline || basics?.label || null,
summary: basics?.summary,
location:
loc && typeof loc === "object"
? {
city: loc.city ?? null,
region: loc.region ?? null,
countryCode: loc.countryCode ?? null,
}
: null,
},
skills: p.sections?.skills ?? null,
experience: experienceItems,

View File

@ -40,6 +40,7 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
tracerLinksEnabled: false,
sponsorMatchScore: null,
sponsorMatchNames: null,
notes: null,
jobType: null,
salarySource: null,
salaryInterval: null,

View File

@ -158,6 +158,7 @@ export interface Job {
coverLetter: string | null; // AI-generated cover letter
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
notes: string | null; // User-written personal notes about this listing
// JobSpy fields (nullable for non-JobSpy sources)
jobType: string | null;
@ -318,4 +319,5 @@ export interface UpdateJobInput {
coverLetter?: string | null;
sponsorMatchScore?: number;
sponsorMatchNames?: string;
notes?: string | null;
}

View File

@ -48,6 +48,9 @@ export type JobAction =
| "rescore"
| "generate_cover_letter";
/** Must match server validation for POST /api/jobs/actions and client bulk selection. */
export const MAX_JOB_ACTION_BATCH_SIZE = 2500;
export type JobActionRequest =
| {
action: "skip" | "rescore" | "generate_cover_letter";