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:
parent
4d7c8ac0bc
commit
60b61ffe03
@ -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 listing’s 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
|
||||
|
||||
114
orchestrator/src/client/components/JobNotes.tsx
Normal file
114
orchestrator/src/client/components/JobNotes.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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", () => ({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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`, {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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'))`),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user