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) ## Features (high level)
- **Sources**: Multiple boards and aggregators (exact list evolves; see docs and extractor packages). - **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. - **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). - **Pipeline**: Scheduled or manual runs (`POST /api/pipeline/run`, webhook trigger).
- **Post-application**: Optional Gmail-based inbox for interview/offer/rejection signals. - **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. - **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). - **Data**: SQLite and generated artifacts under `./data` (default in Docker).
## Requirements ## 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 { useCoverLetterGeneration } from "../hooks/useCoverLetterGeneration";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob"; import { useRescoreJob } from "../hooks/useRescoreJob";
import { FitAssessment, JobHeader, TailoredSummary } from "."; import { FitAssessment, JobHeader, JobNotes, TailoredSummary } from ".";
import { CoverLetterDisplay } from "./CoverLetterDisplay"; import { CoverLetterDisplay } from "./CoverLetterDisplay";
import { TailorMode } from "./discovered-panel/TailorMode"; import { TailorMode } from "./discovered-panel/TailorMode";
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer"; import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
@ -435,6 +435,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
generateInFlight={coverLetterGenerating} generateInFlight={coverLetterGenerating}
/> />
<TailoredSummary job={job} /> <TailoredSummary job={job} />
<JobNotes job={job} onJobUpdated={onJobUpdated} />
{googleDorks.length > 0 ? ( {googleDorks.length > 0 ? (
<ReadySummaryAccordion <ReadySummaryAccordion

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,7 +104,7 @@ describe("useJobSelectionActions", () => {
}); });
it("caps select-all to the API max", () => { 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" }), createJob({ id: `job-${index + 1}`, status: "discovered" }),
); );
const loadJobs = vi.fn().mockResolvedValue(undefined); const loadJobs = vi.fn().mockResolvedValue(undefined);
@ -120,11 +120,12 @@ describe("useJobSelectionActions", () => {
result.current.toggleSelectAll(true); 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 () => { 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" }), createJob({ id: `job-${index + 1}`, status: "discovered" }),
); );
const loadJobs = vi.fn().mockResolvedValue(undefined); const loadJobs = vi.fn().mockResolvedValue(undefined);
@ -137,10 +138,12 @@ describe("useJobSelectionActions", () => {
); );
act(() => { act(() => {
for (const job of activeJobs) { result.current.toggleSelectAll(true);
result.current.toggleSelectJob(job.id);
}
}); });
act(() => {
result.current.toggleSelectJob("job-2501");
});
expect(result.current.selectedJobIds.size).toBe(2501);
await act(async () => { await act(async () => {
await result.current.runJobAction("skip"); await result.current.runJobAction("skip");

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
// Apply schema migrations before any query runs (same DB path as below).
import "./migrate";
import * as schema from "./schema"; import * as schema from "./schema";
// Database path - can be overridden via env for Docker // 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 // Add cover letter column for AI-generated cover letters
`ALTER TABLE jobs ADD COLUMN cover_letter TEXT`, `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 search profiles table for multi-profile support
`CREATE TABLE IF NOT EXISTS search_profiles ( `CREATE TABLE IF NOT EXISTS search_profiles (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -461,6 +464,7 @@ const migrations = [
tracer_links_enabled INTEGER NOT NULL DEFAULT 0, tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
sponsor_match_score REAL, sponsor_match_score REAL,
sponsor_match_names TEXT, sponsor_match_names TEXT,
notes TEXT,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')), discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT, processed_at TEXT,
applied_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, 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, 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, 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 applied_at, created_at, updated_at
) )
SELECT SELECT
@ -486,7 +490,7 @@ const migrations = [
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines, 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, 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, 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 applied_at, created_at, updated_at
FROM jobs`, FROM jobs`,
`DROP TABLE IF EXISTS jobs`, `DROP TABLE IF EXISTS jobs`,

View File

@ -105,6 +105,7 @@ export const jobs = sqliteTable("jobs", {
coverLetter: text("cover_letter"), coverLetter: text("cover_letter"),
sponsorMatchScore: real("sponsor_match_score"), sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"), sponsorMatchNames: text("sponsor_match_names"),
notes: text("notes"),
// Timestamps // Timestamps
discoveredAt: text("discovered_at").notNull().default(sql`(datetime('now'))`), 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, tracerLinksEnabled: row.tracerLinksEnabled ?? false,
sponsorMatchScore: row.sponsorMatchScore ?? null, sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null, sponsorMatchNames: row.sponsorMatchNames ?? null,
notes: row.notes ?? null,
jobType: row.jobType ?? null, jobType: row.jobType ?? null,
salarySource: row.salarySource ?? null, salarySource: row.salarySource ?? null,
salaryInterval: row.salaryInterval ?? null, salaryInterval: row.salaryInterval ?? null,

View File

@ -33,11 +33,22 @@ function buildCoverLetterPrompt(
const parts: string[] = []; const parts: string[] = [];
parts.push( 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("");
parts.push("RULES:"); 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( parts.push(
"- Lead with what makes the candidate a strong fit for THIS specific role", "- 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", "- Reference specific requirements from the job description and match them to candidate experience",
); );
parts.push( parts.push(
"- Be genuine and enthusiastic without being generic or sycophantic", "- Be genuine and enthusiastic without being generic, overstated, 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",
); );
parts.push( 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(""); parts.push("");
@ -63,28 +81,64 @@ function buildCoverLetterPrompt(
if (job.location) parts.push(`Location: ${job.location}`); if (job.location) parts.push(`Location: ${job.location}`);
if (job.jobDescription) { if (job.jobDescription) {
const desc = const desc =
job.jobDescription.length > 3000 job.jobDescription.length > 4000
? `${job.jobDescription.slice(0, 3000)}...` ? `${job.jobDescription.slice(0, 4000)}...`
: job.jobDescription; : job.jobDescription;
parts.push(`Description:\n${desc}`); parts.push(`Description:\n${desc}`);
} }
parts.push(""); parts.push("");
parts.push("=== CANDIDATE RESUME ==="); parts.push("=== CANDIDATE RESUME (structured) ===");
parts.push(JSON.stringify(resumeProfile, null, 2)); parts.push(JSON.stringify(resumeProfile, null, 2));
if (searchProfile) { if (searchProfile) {
parts.push(""); parts.push("");
parts.push("=== CANDIDATE PREFERENCES ==="); parts.push("=== CANDIDATE PREFERENCES (job search profile) ===");
if (searchProfile.aboutMe) { if (searchProfile.aboutMe.trim()) {
parts.push(`About: ${searchProfile.aboutMe}`); parts.push(`About / narrative: ${searchProfile.aboutMe}`);
}
if (searchProfile.experienceLevel.trim()) {
parts.push(`Experience level: ${searchProfile.experienceLevel}`);
} }
if (searchProfile.targetRoles.length > 0) { if (searchProfile.targetRoles.length > 0) {
parts.push(`Target roles: ${searchProfile.targetRoles.join(", ")}`); parts.push(`Target roles: ${searchProfile.targetRoles.join(", ")}`);
} }
if (searchProfile.mustHaveSkills.length > 0) { if (searchProfile.mustHaveSkills.length > 0) {
parts.push( 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( const prompt = buildCoverLetterPrompt(
job, job,
sanitizeProfileForPrompt(resumeProfile), sanitizeProfileForPrompt(resumeProfile, {
maxExperienceItems: 8,
maxProjectItems: 8,
}),
searchProfile, searchProfile,
); );

View File

@ -67,7 +67,7 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
type: "array", type: "array",
items: { type: "string" }, items: { type: "string" },
description: 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: [ required: [
@ -137,23 +137,38 @@ function applySalaryPenalty(
} }
function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null { 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 { return {
roleTypeMatch: roleTypeMatch: hasRoleType
typeof data.roleTypeMatch === "number" ? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch as number)))
? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch)))
: 50, : 50,
workArrangementMatch: workArrangementMatch: hasWorkArrangement
typeof data.workArrangementMatch === "number" ? Math.min(
? Math.min(100, Math.max(0, Math.round(data.workArrangementMatch))) 100,
Math.max(0, Math.round(data.workArrangementMatch as number)),
)
: undefined, : undefined,
strengths: Array.isArray(data.strengths) ? data.strengths : [], strengths,
gaps: Array.isArray(data.gaps) ? data.gaps : [], gaps,
suggestions: Array.isArray(data.suggestions) ? data.suggestions : [], suggestions,
dealBreakerHits: Array.isArray(data.dealBreakerHits) dealBreakerHits,
? data.dealBreakerHits
: [],
}; };
} }
@ -236,6 +251,15 @@ export async function scoreJobSuitability(
clampedScore = remoteCap.score; clampedScore = remoteCap.score;
clampedReason = remoteCap.reason; clampedReason = remoteCap.reason;
const dealBreakerCap = applyDealBreakerHitsCap(
job.id,
clampedScore,
clampedReason,
analysis,
);
clampedScore = dealBreakerCap.score;
clampedReason = dealBreakerCap.reason;
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, { const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
penalizeMissingSalary: settings.penalizeMissingSalary.value, penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty.value, missingSalaryPenalty: settings.missingSalaryPenalty.value,
@ -403,6 +427,31 @@ function applyRemoteOfficeMismatchCap(
return { score: cappedScore, reason: `${reason} ${capNote}` }; 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 { function hasNonEmptyProfile(p: JobSearchProfile): boolean {
return ( return (
p.targetRoles.length > 0 || 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" "Software Engineer in Test" = roleTypeMatch 85-95
* Candidate targets "QA Automation / SDET" "Test Automation Engineer" = 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. - 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. - 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 - 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. 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"). - "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. - "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. - "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: 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"]} {"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): 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( export function sanitizeProfileForPrompt(
profile: Record<string, unknown>, profile: Record<string, unknown>,
options?: { maxExperienceItems?: number; maxProjectItems?: number },
): Record<string, unknown> { ): Record<string, unknown> {
const maxExperienceItems = options?.maxExperienceItems ?? 5;
const maxProjectItems = options?.maxProjectItems ?? 6;
const p = profile as { const p = profile as {
basics?: Record<string, unknown>; basics?: Record<string, unknown>;
sections?: { sections?: {
@ -640,17 +702,27 @@ export function sanitizeProfileForPrompt(
}; };
const experienceItems = Array.isArray(p.sections?.experience?.items) 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) 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 basics = p.basics as Record<string, unknown> | undefined;
const loc = basics?.location as Record<string, unknown> | undefined;
return { return {
basics: { basics: {
name: basics?.name ?? null,
headline: basics?.headline || basics?.label || null, headline: basics?.headline || basics?.label || null,
summary: basics?.summary, 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, skills: p.sections?.skills ?? null,
experience: experienceItems, experience: experienceItems,

View File

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

View File

@ -158,6 +158,7 @@ export interface Job {
coverLetter: string | null; // AI-generated cover letter coverLetter: string | null; // AI-generated cover letter
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors 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) 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) // JobSpy fields (nullable for non-JobSpy sources)
jobType: string | null; jobType: string | null;
@ -318,4 +319,5 @@ export interface UpdateJobInput {
coverLetter?: string | null; coverLetter?: string | null;
sponsorMatchScore?: number; sponsorMatchScore?: number;
sponsorMatchNames?: string; sponsorMatchNames?: string;
notes?: string | null;
} }

View File

@ -48,6 +48,9 @@ export type JobAction =
| "rescore" | "rescore"
| "generate_cover_letter"; | "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 = export type JobActionRequest =
| { | {
action: "skip" | "rescore" | "generate_cover_letter"; action: "skip" | "rescore" | "generate_cover_letter";