feat: search profiles, cover letters, discovery fixes

- Add search profiles (DB, API, settings UI) and wire into scorer/pipeline search terms.
- Add cover letter generation (service, job action, JobDetail UI).
- Align JobSpy Indeed country with country-level search geography when settings conflict; warn in logs.
- Infer country from search cities via inferCountryKeyFromSearchGeography (shared).
- Ignore extractor venv/storage and local data in Biome; ignore orchestrator/storage and JobSpy .venv in git.
- Vite: do not watch orchestrator/storage (prevents reloads during startup.jobs pipeline).
- JobSpy: document Python 3.10+ and venv setup in README/requirements.
- Onboarding and settings: local resume path handling, orchestrator .env.example for Vite.

Made-with: Cursor
This commit is contained in:
ilia 2026-04-05 19:35:14 -04:00
parent 0a7dbb4f16
commit fea00ae656
54 changed files with 2449 additions and 193 deletions

View File

@ -12,6 +12,12 @@ MODEL=google/gemini-3-flash-preview
# Defaults to https://v4.rxresu.me
# RXRESUME_URL=
# Optional: load resume JSON from disk instead of the RxResume API (scoring, tailoring, cover letters).
# Path is absolute or relative to the orchestrator process cwd (often `orchestrator/` when using `npm run dev` there).
# Takes precedence over Settings → local path. PDF export still uses RxResume when enabled.
# Example (monorepo): hand-authored v5 JSON may live under `data/resumes/` (that folder is gitignored by default).
# JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json
# RXResume credentials for PDF generation
# Create an account at: https://v4.rxresu.me
RXRESUME_EMAIL=your_email@example.com
@ -24,8 +30,10 @@ BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD=
# Optional: client build only — skip RxResume steps in the onboarding wizard (search without PDF export).
# - Local dev: set here or export before `npm run dev` / `npm run build:client` in orchestrator.
# - Docker: set at IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env.
# Prefer setting `JOBOPS_LOCAL_RESUME_PATH` above: the API tells the UI to skip RxResume onboarding automatically.
# Otherwise: copy `orchestrator/.env.example` → `orchestrator/.env` and set VITE_SKIP_RXRESUME_ONBOARDING=true
# (Vite only reads `orchestrator/.env`, not this root file.)
# Docker: Vite vars need IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env.
# VITE_SKIP_RXRESUME_ONBOARDING=true
# Public base URL used to generate tracer links when PDFs are created by

View File

@ -9,8 +9,12 @@
"includes": [
"**",
"!!**/dist",
"!!**/.venv",
"!!docs-site/.docusaurus",
"!!docs-site/build"
"!!docs-site/build",
"!!extractors/jobspy/storage",
"!!orchestrator/storage",
"!!data"
]
},
"css": {

2
extractors/jobspy/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.venv/
storage/

View File

@ -0,0 +1,34 @@
# JobSpy extractor (Indeed / LinkedIn / Glassdoor)
## Requirements
- **Python 3.10 or newer** (`python-jobspy` has no compatible wheels on older versions).
- A virtualenv in this directory (`.venv`) is recommended.
```bash
# Example: Homebrew (Apple Silicon paths may use /opt/homebrew)
python3.12 --version # or python3.11 / python3.10 — must be ≥ 3.10
rm -rf .venv
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements.txt
```
The orchestrator uses `.venv/bin/python3` when present, otherwise system `python3`.
## Manual test
```bash
source .venv/bin/activate
export JOBSPY_SITES=indeed
export JOBSPY_SEARCH_TERM="software engineer"
export JOBSPY_LOCATION="Toronto"
export JOBSPY_COUNTRY_INDEED=canada
export JOBSPY_OUTPUT_JSON=/tmp/jobspy-out.json
export JOBSPY_OUTPUT_CSV=/tmp/jobspy-out.csv
python3 scrape_jobs.py
```
Adjust env vars to match what the app passes (see `extractors/jobspy/src/run.ts`).

View File

@ -1,2 +1,3 @@
# python-jobspy requires Python 3.10+ (wheels not published for 3.9 and below).
python-jobspy
pandas

View File

@ -0,0 +1,6 @@
# Copy to `.env` in this folder if you want Vite-only overrides.
# The dev server still loads the monorepo root `.env` for API settings.
#
# Optional: skip Reactive Resume steps in the onboarding wizard (client build / `npm run dev`).
# Not required if `JOBOPS_LOCAL_RESUME_PATH` is set in the root `.env` — the API exposes that to the UI.
# VITE_SKIP_RXRESUME_ONBOARDING=true

View File

@ -7,6 +7,9 @@ dist/
# Data (local database and generated files)
data/
# Extractor / Apify-style KV written under cwd during pipeline runs
storage/
# Environment
.env
.env.local

View File

@ -20,6 +20,7 @@ import type {
JobChatThread,
JobListItem,
JobOutcome,
JobSearchProfile,
JobSource,
JobsListResponse,
JobsRevisionResponse,
@ -39,6 +40,7 @@ import type {
ResumeProfile,
ResumeProjectCatalogItem,
RxResumeMode,
SearchProfile,
StageEvent,
StageEventMetadata,
StageTransitionTarget,
@ -1509,3 +1511,46 @@ export async function deleteBackup(filename: string): Promise<void> {
method: "DELETE",
});
}
// Profiles API
export async function listProfiles(): Promise<SearchProfile[]> {
return fetchApi<SearchProfile[]>("/profiles");
}
export async function createProfile(input: {
name: string;
data: JobSearchProfile;
}): Promise<SearchProfile> {
return fetchApi<SearchProfile>("/profiles", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateProfile(
id: string,
input: { name?: string; data?: JobSearchProfile },
): Promise<SearchProfile> {
return fetchApi<SearchProfile>(`/profiles/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteProfile(id: string): Promise<void> {
await fetchApi<void>(`/profiles/${id}`, {
method: "DELETE",
});
}
export async function activateProfile(id: string): Promise<void> {
await fetchApi<void>(`/profiles/${id}/activate`, {
method: "POST",
});
}
export async function generateProfileFromResume(): Promise<JobSearchProfile> {
return fetchApi<JobSearchProfile>("/profiles/generate-from-resume", {
method: "POST",
});
}

View File

@ -0,0 +1,72 @@
import type { Job } from "@shared/types.js";
import { Check, Copy, FileText } from "lucide-react";
import type React from "react";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
interface CoverLetterDisplayProps {
job: Job;
className?: string;
}
export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
job,
className,
}) => {
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const handleCopy = useCallback(async () => {
if (!job.coverLetter) return;
await navigator.clipboard.writeText(job.coverLetter);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [job.coverLetter]);
if (!job.coverLetter) return null;
const lines = job.coverLetter.split("\n");
const isLong = lines.length > 8;
const displayText = expanded ? job.coverLetter : lines.slice(0, 6).join("\n");
return (
<div className={cn("space-y-1", className)}>
<div className="rounded-lg border border-violet-500/20 bg-violet-500/5 px-3 py-2.5">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-violet-600/80 dark:text-violet-400/80 flex items-center gap-1.5">
<FileText className="h-3 w-3" />
Cover Letter
</div>
<button
type="button"
onClick={handleCopy}
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
{copied ? (
<>
<Check className="h-3 w-3" /> Copied
</>
) : (
<>
<Copy className="h-3 w-3" /> Copy
</>
)}
</button>
</div>
<p className="text-xs text-foreground/85 leading-relaxed whitespace-pre-wrap">
{displayText}
{isLong && !expanded && "..."}
</p>
{isLong && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-1.5 text-[10px] text-violet-600/70 dark:text-violet-400/70 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
{expanded ? "Show less" : "Show full letter"}
</button>
)}
</div>
</div>
);
};

View File

@ -1,6 +1,13 @@
import type { Job } from "@shared/types.js";
import { Sparkles } from "lucide-react";
import type { Job, SuitabilityAnalysis } from "@shared/types.js";
import {
AlertTriangle,
CheckCircle2,
Lightbulb,
Sparkles,
XCircle,
} from "lucide-react";
import type React from "react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
interface FitAssessmentProps {
@ -8,23 +15,156 @@ interface FitAssessmentProps {
className?: string;
}
function parseAnalysis(raw: string | null): SuitabilityAnalysis | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && Array.isArray(parsed.strengths))
return parsed as SuitabilityAnalysis;
return null;
} catch {
return null;
}
}
function RoleMatchBadge({ score }: { score: number }) {
const color =
score >= 70
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
: score >= 40
? "text-amber-500 bg-amber-500/10 border-amber-500/20"
: "text-red-500 bg-red-500/10 border-red-500/20";
const label =
score >= 70
? "Strong role match"
: score >= 40
? "Partial role match"
: "Weak role match";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium",
color,
)}
>
{label} ({score}%)
</span>
);
}
export const FitAssessment: React.FC<FitAssessmentProps> = ({
job,
className,
}) => {
if (!job.suitabilityReason) return null;
const analysis = useMemo(
() => parseAnalysis(job.suitabilityAnalysis ?? null),
[job.suitabilityAnalysis],
);
if (!job.suitabilityReason && !analysis) return null;
return (
<div className={cn("space-y-3", className)}>
<div className={cn("space-y-2", className)}>
{/* Summary / Reason */}
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-primary/70 mb-1.5 flex items-center gap-1.5">
<Sparkles className="h-3 w-3" />
Fit Assessment
{analysis && <RoleMatchBadge score={analysis.roleTypeMatch} />}
</div>
{job.suitabilityReason && (
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
{job.suitabilityReason}
</p>
)}
</div>
{analysis && (
<div className="grid gap-2 sm:grid-cols-2">
{/* Deal-Breaker Hits */}
{analysis.dealBreakerHits.length > 0 && (
<div className="sm:col-span-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-destructive/80 mb-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Deal-breakers triggered
</div>
<ul className="space-y-0.5">
{analysis.dealBreakerHits.map((hit) => (
<li
key={hit}
className="text-xs text-destructive/90 flex items-start gap-1.5"
>
<XCircle className="h-3 w-3 mt-0.5 shrink-0" />
{hit}
</li>
))}
</ul>
</div>
)}
{/* Strengths */}
{analysis.strengths.length > 0 && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-emerald-600/80 dark:text-emerald-400/80 mb-1 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Strengths
</div>
<ul className="space-y-0.5">
{analysis.strengths.map((s) => (
<li
key={s}
className="text-xs text-foreground/80 leading-relaxed"
>
{s}
</li>
))}
</ul>
</div>
)}
{/* Gaps */}
{analysis.gaps.length > 0 && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-amber-600/80 dark:text-amber-400/80 mb-1 flex items-center gap-1">
<XCircle className="h-3 w-3" />
Gaps
</div>
<ul className="space-y-0.5">
{analysis.gaps.map((g) => (
<li
key={g}
className="text-xs text-foreground/80 leading-relaxed"
>
{g}
</li>
))}
</ul>
</div>
)}
{/* Suggestions */}
{analysis.suggestions.length > 0 && (
<div className="sm:col-span-2 rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-blue-600/80 dark:text-blue-400/80 mb-1 flex items-center gap-1">
<Lightbulb className="h-3 w-3" />
Suggestions to improve fit
</div>
<ul className="space-y-0.5">
{analysis.suggestions.map((s) => (
<li
key={s}
className="text-xs text-foreground/80 leading-relaxed"
>
{s}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
};

View File

@ -110,6 +110,8 @@ const settingsResponse = {
rxresumeApiKeyHint: null,
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,
localResumeProfilePath: null,
localResumeFileConfigured: false,
},
isLoading: false,
refreshSettings: vi.fn(),
@ -178,6 +180,25 @@ describe("OnboardingGate", () => {
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
it("hides the gate for Ollama when local resume file is configured on the server", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
llmProvider: { value: "ollama", default: "ollama", override: null },
localResumeFileConfigured: true,
},
} as any);
render(<OnboardingGate />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
expect(api.validateRxresume).not.toHaveBeenCalled();
expect(api.validateResumeConfig).not.toHaveBeenCalled();
});
it("skips LLM key validation for providers without API keys", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,

View File

@ -94,14 +94,20 @@ function getStepPrimaryLabel(input: {
}
export const OnboardingGate: React.FC = () => {
/** Opt-in: set `VITE_SKIP_RXRESUME_ONBOARDING=true` at build/dev time to skip RxResume steps in onboarding. */
const skipRxResumeOnboarding =
import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true";
const {
settings,
isLoading: settingsLoading,
refreshSettings,
} = useSettings();
/** Skip RxResume onboarding when Vite flag is set, server reports a local resume file, or Settings has a local path. */
const skipRxResumeOnboarding = useMemo(
() =>
import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true" ||
Boolean(settings?.localResumeFileConfigured) ||
Boolean(settings?.localResumeProfilePath?.trim()),
[settings?.localResumeFileConfigured, settings?.localResumeProfilePath],
);
const {
storedRxResume,
getBaseResumeIdForMode,

View File

@ -1,3 +1,4 @@
export { CoverLetterDisplay } from "./CoverLetterDisplay";
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
export { FitAssessment } from "./FitAssessment";
export { JobHeader } from "./JobHeader";

View File

@ -186,6 +186,7 @@ export const OrchestratorPage: React.FC = () => {
canSkipSelected,
canMoveSelected,
canRescoreSelected,
canGenerateCoverLetter,
jobActionInFlight,
toggleSelectJob,
toggleSelectAll,
@ -437,10 +438,12 @@ export const OrchestratorPage: React.FC = () => {
canMoveSelected={canMoveSelected}
canSkipSelected={canSkipSelected}
canRescoreSelected={canRescoreSelected}
canGenerateCoverLetter={canGenerateCoverLetter}
jobActionInFlight={jobActionInFlight !== null}
onMoveToReady={() => void runJobAction("move_to_ready")}
onSkipSelected={() => void runJobAction("skip")}
onRescoreSelected={() => void runJobAction("rescore")}
onGenerateCoverLetter={() => void runJobAction("generate_cover_letter")}
onClear={clearSelection}
/>

View File

@ -19,6 +19,7 @@ import { ChatSettingsSection } from "@client/pages/settings/components/ChatSetti
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection";
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
@ -60,6 +61,7 @@ import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
jobSearchProfile: null,
model: "",
modelScorer: "",
modelTailoring: "",
@ -82,6 +84,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
chatStyleManualLanguage: null,
rxresumeEmail: "",
rxresumeUrl: "",
localResumeProfilePath: "",
rxresumePassword: "",
rxresumeApiKey: "",
basicAuthUser: "",
@ -137,6 +140,7 @@ const normalizeLlmProviderValue = (
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
jobSearchProfile: null,
model: null,
modelScorer: null,
modelTailoring: null,
@ -159,6 +163,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
chatStyleManualLanguage: null,
rxresumeEmail: null,
rxresumeUrl: null,
localResumeProfilePath: null,
rxresumePassword: null,
rxresumeApiKey: null,
basicAuthUser: null,
@ -181,6 +186,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
jobSearchProfile: data.jobSearchProfile?.override ?? null,
model: data.model.override ?? "",
modelScorer: data.modelScorer.override ?? "",
modelTailoring: data.modelTailoring.override ?? "",
@ -204,6 +210,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
rxresumeEmail: data.rxresumeEmail ?? "",
rxresumeUrl: data.rxresumeUrl ?? "",
localResumeProfilePath: data.localResumeProfilePath ?? "",
rxresumePassword: "",
rxresumeApiKey: "",
basicAuthUser: data.basicAuthUser ?? "",
@ -370,6 +377,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
default: settings?.backupMaxCount?.default ?? 5,
},
},
jobSearchProfile: {
effective: settings?.jobSearchProfile?.value ?? null,
default: settings?.jobSearchProfile?.default ?? null,
},
scoring: {
penalizeMissingSalary: {
effective: settings?.penalizeMissingSalary?.value ?? false,
@ -572,6 +583,7 @@ export const SettingsPage: React.FC = () => {
profileProjects,
backup,
scoring,
jobSearchProfile,
} = derived;
const handleCreateBackup = async () => {
@ -769,6 +781,12 @@ export const SettingsPage: React.FC = () => {
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
}
if (dirtyFields.localResumeProfilePath) {
envPayload.localResumeProfilePath = normalizeString(
data.localResumeProfilePath,
);
}
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
}
@ -833,7 +851,14 @@ export const SettingsPage: React.FC = () => {
if (value !== undefined) envPayload.webhookSecret = value;
}
const jobSearchProfilePayload = dirtyFields.jobSearchProfile
? data.jobSearchProfile
: undefined;
const payload: Partial<UpdateSettingsInput> = {
...(jobSearchProfilePayload !== undefined
? { jobSearchProfile: jobSearchProfilePayload }
: {}),
model: dirtyFields.llmProvider
? dirtyFields.model
? normalizeString(data.model)
@ -986,6 +1011,21 @@ export const SettingsPage: React.FC = () => {
}
const updated = await updateSettingsMutation.mutateAsync(payload);
if (
dirtyFields.jobSearchProfile &&
data.jobSearchProfile &&
updated.activeProfileId
) {
try {
await api.updateProfile(updated.activeProfileId, {
data: data.jobSearchProfile,
});
} catch {
// Profile sync is best-effort; settings are already saved
}
}
setSettings(updated);
reset(mapSettingsToForm(updated));
toast.success("Settings saved");
@ -1167,6 +1207,12 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<JobSearchProfileSection
values={jobSearchProfile}
activeProfileId={settings?.activeProfileId ?? null}
isLoading={isLoading}
isSaving={isSaving}
/>
<ScoringSettingsSection
values={scoring}
isLoading={isLoading}

View File

@ -7,10 +7,12 @@ interface FloatingJobActionsBarProps {
canMoveSelected: boolean;
canSkipSelected: boolean;
canRescoreSelected: boolean;
canGenerateCoverLetter: boolean;
jobActionInFlight: boolean;
onMoveToReady: () => void;
onSkipSelected: () => void;
onRescoreSelected: () => void;
onGenerateCoverLetter: () => void;
onClear: () => void;
}
@ -19,10 +21,12 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
canMoveSelected,
canSkipSelected,
canRescoreSelected,
canGenerateCoverLetter,
jobActionInFlight,
onMoveToReady,
onSkipSelected,
onRescoreSelected,
onGenerateCoverLetter,
onClear,
}) => {
return (
@ -76,6 +80,18 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
Recalculate match
</Button>
)}
{canGenerateCoverLetter && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onGenerateCoverLetter}
>
Cover letter
</Button>
)}
<Button
type="button"
size="sm"

View File

@ -52,6 +52,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
});
vi.mock("@client/components", () => ({
CoverLetterDisplay: () => <div data-testid="cover-letter-display" />,
DiscoveredPanel: ({ job }: { job: Job | null }) => (
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
),

View File

@ -1,5 +1,6 @@
import * as api from "@client/api";
import {
CoverLetterDisplay,
DiscoveredPanel,
FitAssessment,
JobHeader,
@ -601,6 +602,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<TabsContent value="overview" className="space-y-3 pt-2">
<FitAssessment job={selectedJob} />
<CoverLetterDisplay job={selectedJob} />
<TailoredSummary job={selectedJob} />
<div className="grid gap-2 text-xs sm:grid-cols-2">

View File

@ -16,6 +16,10 @@ export function canRescore(jobs: JobListItem[]): boolean {
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
}
export function canGenerateCoverLetter(jobs: JobListItem[]): boolean {
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
}
export function getFailedJobIds(response: JobActionResponse): Set<string> {
const failedIds = response.results
.filter((result) => !result.ok)

View File

@ -10,6 +10,7 @@ import { trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast";
import {
canGenerateCoverLetter,
canMoveToReady,
canRescore,
canSkip,
@ -23,12 +24,14 @@ const jobActionLabel: Record<JobAction, string> = {
move_to_ready: "Moving jobs to Ready...",
skip: "Skipping selected jobs...",
rescore: "Calculating match scores...",
generate_cover_letter: "Generating cover letters...",
};
const jobActionSuccessLabel: Record<JobAction, string> = {
move_to_ready: "jobs moved to Ready",
skip: "jobs skipped",
rescore: "matches recalculated",
generate_cover_letter: "cover letters generated",
};
interface UseJobSelectionActionsArgs {
@ -64,6 +67,10 @@ export function useJobSelectionActions({
() => canRescore(selectedJobs),
[selectedJobs],
);
const canGenerateCoverLetterSelected = useMemo(
() => canGenerateCoverLetter(selectedJobs),
[selectedJobs],
);
useEffect(() => {
if (previousActiveTabRef.current === activeTab) return;
@ -283,6 +290,7 @@ export function useJobSelectionActions({
canSkipSelected,
canMoveSelected,
canRescoreSelected,
canGenerateCoverLetter: canGenerateCoverLetterSelected,
jobActionInFlight,
toggleSelectJob,
toggleSelectAll,

View File

@ -0,0 +1,382 @@
import { TokenizedInput } from "@client/pages/orchestrator/TokenizedInput";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { JobSearchProfile } from "@shared/types.js";
import { Target, User } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { ProfileManager } from "./ProfileManager";
type JobSearchProfileSectionProps = {
values: {
effective: JobSearchProfile | null;
default: JobSearchProfile | null;
};
activeProfileId: string | null;
isLoading: boolean;
isSaving: boolean;
};
function parseTokens(input: string): string[] {
return input
.split(/[\n,]/g)
.map((v) => v.trim())
.filter(Boolean);
}
const EMPTY_PROFILE: JobSearchProfile = {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
};
const EXPERIENCE_LEVELS = [
{ value: "", label: "Not specified" },
{ value: "intern", label: "Intern / Placement" },
{ value: "graduate", label: "Graduate / Entry-level" },
{ value: "junior", label: "Junior (0-2 years)" },
{ value: "mid", label: "Mid-level (2-5 years)" },
{ value: "senior", label: "Senior (5+ years)" },
{ value: "lead", label: "Lead / Principal" },
];
const WORK_ARRANGEMENTS = [
{ value: "remote", label: "Remote" },
{ value: "hybrid", label: "Hybrid" },
{ value: "onsite", label: "On-site" },
];
export const JobSearchProfileSection: React.FC<
JobSearchProfileSectionProps
> = ({ values, activeProfileId, isLoading, isSaving }) => {
const { watch, setValue } = useFormContext<UpdateSettingsInput>();
const [targetRoleDraft, setTargetRoleDraft] = useState("");
const [mustHaveSkillDraft, setMustHaveSkillDraft] = useState("");
const [niceToHaveSkillDraft, setNiceToHaveSkillDraft] = useState("");
const [dealBreakerDraft, setDealBreakerDraft] = useState("");
const [locationDraft, setLocationDraft] = useState("");
const [targetIndustryDraft, setTargetIndustryDraft] = useState("");
const [avoidIndustryDraft, setAvoidIndustryDraft] = useState("");
const current: JobSearchProfile =
watch("jobSearchProfile") ?? values.effective ?? EMPTY_PROFILE;
const updateField = <K extends keyof JobSearchProfile>(
key: K,
val: JobSearchProfile[K],
) => {
setValue(
"jobSearchProfile",
{ ...current, [key]: val },
{ shouldDirty: true },
);
};
return (
<AccordionItem value="jobSearchProfile" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold flex items-center gap-2">
<User className="h-4 w-4" />
Job Search Profile
</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<ProfileManager
activeProfileId={activeProfileId}
disabled={isLoading || isSaving}
/>
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Tell the AI what kind of jobs you're looking for. This dramatically
improves scoring accuracy a Full Stack Dev job won't score high if
you're an Automation Tester.
</p>
{/* About Me */}
<div className="space-y-2">
<label
htmlFor="about-me"
className="text-sm font-medium leading-none"
>
About You & What You're Looking For
</label>
<Textarea
id="about-me"
value={current.aboutMe}
onChange={(e) => updateField("aboutMe", e.target.value)}
placeholder="I'm a QA automation engineer with 2 years experience in Playwright and Cypress. Looking for SDET or automation tester roles, preferably at product companies. Not interested in manual QA or full-stack development."
disabled={isLoading || isSaving}
maxLength={4000}
rows={4}
/>
<p className="text-xs text-muted-foreground">
Free-form description of who you are and what you want. This gives
the AI scorer the most context.
</p>
</div>
<Separator />
{/* Target Roles */}
<div className="space-y-2">
<label
htmlFor="target-roles"
className="text-sm font-medium leading-none flex items-center gap-1.5"
>
<Target className="h-3.5 w-3.5" />
Target Role Titles
</label>
<TokenizedInput
id="target-roles"
values={current.targetRoles}
draft={targetRoleDraft}
parseInput={parseTokens}
onDraftChange={setTargetRoleDraft}
onValuesChange={(vals) => updateField("targetRoles", vals)}
placeholder='e.g. "Automation Tester", "SDET", "QA Engineer"'
helperText="The kinds of roles you actually want. Jobs matching these titles score much higher."
removeLabelPrefix="Remove role"
disabled={isLoading || isSaving}
/>
</div>
{/* Experience Level */}
<div className="space-y-2">
<label
htmlFor="experience-level"
className="text-sm font-medium leading-none"
>
Experience Level
</label>
<select
id="experience-level"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={current.experienceLevel}
onChange={(e) => updateField("experienceLevel", e.target.value)}
disabled={isLoading || isSaving}
>
{EXPERIENCE_LEVELS.map((lvl) => (
<option key={lvl.value} value={lvl.value}>
{lvl.label}
</option>
))}
</select>
</div>
<Separator />
{/* Must-Have Skills */}
<div className="space-y-2">
<label
htmlFor="must-have-skills"
className="text-sm font-medium leading-none"
>
Must-Have Skills
</label>
<TokenizedInput
id="must-have-skills"
values={current.mustHaveSkills}
draft={mustHaveSkillDraft}
parseInput={parseTokens}
onDraftChange={setMustHaveSkillDraft}
onValuesChange={(vals) => updateField("mustHaveSkills", vals)}
placeholder='e.g. "Playwright", "TypeScript", "CI/CD"'
helperText="Skills you consider essential. Jobs missing these will score lower."
removeLabelPrefix="Remove skill"
disabled={isLoading || isSaving}
/>
</div>
{/* Nice-to-Have Skills */}
<div className="space-y-2">
<label
htmlFor="nice-to-have-skills"
className="text-sm font-medium leading-none"
>
Nice-to-Have Skills
</label>
<TokenizedInput
id="nice-to-have-skills"
values={current.niceToHaveSkills}
draft={niceToHaveSkillDraft}
parseInput={parseTokens}
onDraftChange={setNiceToHaveSkillDraft}
onValuesChange={(vals) => updateField("niceToHaveSkills", vals)}
placeholder='e.g. "Docker", "AWS", "Python"'
helperText="Bonus skills. Jobs with these get a small boost."
removeLabelPrefix="Remove skill"
disabled={isLoading || isSaving}
/>
</div>
<Separator />
{/* Deal-Breakers */}
<div className="space-y-2">
<label
htmlFor="deal-breakers"
className="text-sm font-medium leading-none text-destructive"
>
Deal-Breakers
</label>
<TokenizedInput
id="deal-breakers"
values={current.dealBreakers}
draft={dealBreakerDraft}
parseInput={parseTokens}
onDraftChange={setDealBreakerDraft}
onValuesChange={(vals) => updateField("dealBreakers", vals)}
placeholder='e.g. "sales engineering", "10+ years required", "unpaid"'
helperText="If a job matches these, it gets scored 0-15 regardless of other factors."
removeLabelPrefix="Remove deal-breaker"
disabled={isLoading || isSaving}
/>
</div>
<Separator />
{/* Work Arrangement */}
<div className="space-y-2">
<label
htmlFor="work-arrangement"
className="text-sm font-medium leading-none"
>
Preferred Work Arrangement
</label>
<div className="flex flex-wrap gap-2">
{WORK_ARRANGEMENTS.map((wa) => {
const isSelected = current.preferredWorkArrangement.includes(
wa.value,
);
return (
<button
key={wa.value}
type="button"
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted"
}`}
onClick={() => {
const next = isSelected
? current.preferredWorkArrangement.filter(
(v) => v !== wa.value,
)
: [...current.preferredWorkArrangement, wa.value];
updateField("preferredWorkArrangement", next);
}}
disabled={isLoading || isSaving}
>
{wa.label}
</button>
);
})}
</div>
</div>
{/* Preferred Locations */}
<div className="space-y-2">
<label
htmlFor="preferred-locations"
className="text-sm font-medium leading-none"
>
Preferred Locations
</label>
<TokenizedInput
id="preferred-locations"
values={current.preferredLocations}
draft={locationDraft}
parseInput={parseTokens}
onDraftChange={setLocationDraft}
onValuesChange={(vals) => updateField("preferredLocations", vals)}
placeholder='e.g. "London", "Remote UK", "Leeds"'
helperText="Where you want to work. Leave empty for any location."
removeLabelPrefix="Remove location"
disabled={isLoading || isSaving}
/>
</div>
{/* Minimum Salary */}
<div className="space-y-2">
<label
htmlFor="minimum-salary"
className="text-sm font-medium leading-none"
>
Minimum Salary
</label>
<Input
id="minimum-salary"
value={current.minimumSalary}
onChange={(e) => updateField("minimumSalary", e.target.value)}
placeholder='e.g. "£35,000" or "40000"'
disabled={isLoading || isSaving}
maxLength={100}
/>
</div>
<Separator />
{/* Industries to Target */}
<div className="space-y-2">
<label
htmlFor="target-industries"
className="text-sm font-medium leading-none"
>
Target Industries
</label>
<TokenizedInput
id="target-industries"
values={current.industriesToTarget}
draft={targetIndustryDraft}
parseInput={parseTokens}
onDraftChange={setTargetIndustryDraft}
onValuesChange={(vals) => updateField("industriesToTarget", vals)}
placeholder='e.g. "fintech", "healthtech", "SaaS"'
helperText="Industries you prefer. Leave empty for any."
removeLabelPrefix="Remove industry"
disabled={isLoading || isSaving}
/>
</div>
{/* Industries to Avoid */}
<div className="space-y-2">
<label
htmlFor="avoid-industries"
className="text-sm font-medium leading-none"
>
Industries to Avoid
</label>
<TokenizedInput
id="avoid-industries"
values={current.industriesToAvoid}
draft={avoidIndustryDraft}
parseInput={parseTokens}
onDraftChange={setAvoidIndustryDraft}
onValuesChange={(vals) => updateField("industriesToAvoid", vals)}
placeholder='e.g. "gambling", "defense", "crypto"'
helperText="Industries you want to exclude."
removeLabelPrefix="Remove industry"
disabled={isLoading || isSaving}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -0,0 +1,252 @@
import * as api from "@client/api";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { JobSearchProfile, SearchProfile } from "@shared/types.js";
import { Loader2, Plus, Sparkles, Trash2 } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const EMPTY_PROFILE: JobSearchProfile = {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
};
interface ProfileManagerProps {
activeProfileId: string | null;
disabled: boolean;
}
export const ProfileManager: React.FC<ProfileManagerProps> = ({
activeProfileId,
disabled,
}) => {
const { setValue } = useFormContext<UpdateSettingsInput>();
const [profiles, setProfiles] = useState<SearchProfile[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(activeProfileId);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [newProfileName, setNewProfileName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const loadProfiles = useCallback(async () => {
try {
const list = await api.listProfiles();
setProfiles(list);
} catch {
toast.error("Failed to load profiles");
}
}, []);
useEffect(() => {
void loadProfiles();
}, [loadProfiles]);
useEffect(() => {
setSelectedId(activeProfileId);
}, [activeProfileId]);
const handleSelect = useCallback(
async (profileId: string) => {
setLoading(true);
try {
await api.activateProfile(profileId);
setSelectedId(profileId);
const profile = profiles.find((p) => p.id === profileId);
if (profile) {
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profileId, { shouldDirty: false });
}
toast.success("Profile activated");
} catch {
toast.error("Failed to activate profile");
} finally {
setLoading(false);
}
},
[profiles, setValue],
);
const handleCreateBlank = useCallback(async () => {
if (!newProfileName.trim()) {
toast.error("Enter a profile name");
return;
}
setLoading(true);
try {
const profile = await api.createProfile({
name: newProfileName.trim(),
data: EMPTY_PROFILE,
});
await api.activateProfile(profile.id);
setProfiles((prev) => [...prev, profile]);
setSelectedId(profile.id);
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profile.id, { shouldDirty: false });
setNewProfileName("");
setShowCreate(false);
toast.success(`Profile "${profile.name}" created`);
} catch {
toast.error("Failed to create profile");
} finally {
setLoading(false);
}
}, [newProfileName, setValue]);
const handleGenerateFromResume = useCallback(async () => {
if (!newProfileName.trim()) {
toast.error("Enter a profile name first");
return;
}
setGenerating(true);
try {
const generated = await api.generateProfileFromResume();
const profile = await api.createProfile({
name: newProfileName.trim(),
data: generated,
});
await api.activateProfile(profile.id);
setProfiles((prev) => [...prev, profile]);
setSelectedId(profile.id);
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profile.id, { shouldDirty: false });
setNewProfileName("");
setShowCreate(false);
toast.success(`Profile "${profile.name}" created from your resume`);
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to generate profile";
toast.error(msg);
} finally {
setGenerating(false);
}
}, [newProfileName, setValue]);
const handleDelete = useCallback(
async (profileId: string) => {
try {
await api.deleteProfile(profileId);
setProfiles((prev) => prev.filter((p) => p.id !== profileId));
if (selectedId === profileId) {
setSelectedId(null);
setValue("jobSearchProfile", EMPTY_PROFILE, { shouldDirty: false });
setValue("activeProfileId", null, { shouldDirty: false });
}
toast.success("Profile deleted");
} catch {
toast.error("Failed to delete profile");
}
},
[selectedId, setValue],
);
const activeProfile = profiles.find((p) => p.id === selectedId);
return (
<div className="space-y-3 mb-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<select
className="flex h-9 flex-1 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={selectedId ?? ""}
onChange={(e) => {
if (e.target.value) void handleSelect(e.target.value);
}}
disabled={disabled || loading || generating}
>
<option value="">
{profiles.length === 0
? "No profiles — create one"
: "Select a profile..."}
</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setShowCreate(!showCreate)}
disabled={disabled || generating}
>
<Plus className="h-3.5 w-3.5 mr-1" />
New
</Button>
{activeProfile && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(activeProfile.id)}
disabled={disabled || generating}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{showCreate && (
<div className="flex items-center gap-2 p-3 rounded-lg border border-dashed bg-muted/30">
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name (e.g. QA Engineer, DevOps)"
className="flex-1 h-8 text-sm"
disabled={generating}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreateBlank();
}
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCreateBlank}
disabled={!newProfileName.trim() || generating}
>
Create blank
</Button>
<Button
type="button"
size="sm"
onClick={handleGenerateFromResume}
disabled={!newProfileName.trim() || generating}
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1" />
)}
{generating ? "Analysing resume..." : "Generate from resume"}
</Button>
</div>
)}
</div>
{!selectedId && profiles.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Create a profile to get started. "Generate from resume" will analyse
your Reactive Resume and fill in the fields automatically.
</p>
)}
</div>
);
};

View File

@ -13,6 +13,8 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null;
@ -73,6 +75,8 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
const rxresumePasswordValue =
useWatch({ control, name: "rxresumePassword" }) ?? "";
const localResumeProfilePathValue =
useWatch({ control, name: "localResumeProfilePath" }) ?? "";
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
const setDirtyTouchedValue = <TField extends Path<UpdateSettingsInput>>(
field: TField,
@ -97,7 +101,31 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<AccordionContent className="pb-4 space-y-6">
<div className="space-y-2 rounded-md border border-dashed p-3">
<Label htmlFor="local-resume-profile-path">
Local resume JSON (no API)
</Label>
<Input
id="local-resume-profile-path"
type="text"
autoComplete="off"
placeholder="/absolute/or/relative/path/to/resume.json"
value={localResumeProfilePathValue}
disabled={isLoading || isSaving}
onChange={(e) =>
setDirtyTouchedValue("localResumeProfilePath", e.target.value)
}
/>
<p className="text-muted-foreground text-xs">
Reactive Resume export JSON on this machine. When set, it overrides
the RxResume API for scoring, cover letters, and PDF tailoring.
Relative paths use the server process working directory. You can
instead set{" "}
<span className="font-mono">JOBOPS_LOCAL_RESUME_PATH</span> in{" "}
<span className="font-mono">.env</span> (wins over this field).
</p>
</div>
<ReactiveResumeConfigPanel
mode={selectedMode}
onModeChange={(mode) => {

View File

@ -14,6 +14,7 @@ import { pipelineRouter } from "./routes/pipeline";
import { postApplicationProvidersRouter } from "./routes/post-application-providers";
import { postApplicationReviewRouter } from "./routes/post-application-review";
import { profileRouter } from "./routes/profile";
import { profilesRouter } from "./routes/profiles";
import { settingsRouter } from "./routes/settings";
import { tracerLinksRouter } from "./routes/tracer-links";
import { visaSponsorsRouter } from "./routes/visa-sponsors";
@ -31,6 +32,7 @@ apiRouter.use("/post-application", postApplicationReviewRouter);
apiRouter.use("/manual-jobs", manualJobsRouter);
apiRouter.use("/webhook", webhookRouter);
apiRouter.use("/profile", profileRouter);
apiRouter.use("/profiles", profilesRouter);
apiRouter.use("/database", databaseRouter);
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
apiRouter.use("/onboarding", onboardingRouter);

View File

@ -558,6 +558,7 @@ describe.sequential("Jobs API routes", () => {
vi.mocked(scoreJobSuitability).mockResolvedValue({
score: 81,
reason: "Updated fit from action rescore",
analysis: null,
});
const discovered = await createJob({
@ -754,6 +755,7 @@ describe.sequential("Jobs API routes", () => {
vi.mocked(scoreJobSuitability).mockResolvedValue({
score: 77,
reason: "Updated fit",
analysis: null,
});
const job = await createJob({

View File

@ -210,6 +210,10 @@ const jobActionRequestSchema = z.discriminatedUnion("action", [
action: z.literal("rescore"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
}),
z.object({
action: z.literal("generate_cover_letter"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
}),
z.object({
action: z.literal("move_to_ready"),
jobIds: z.array(z.string().min(1)).min(1).max(100),
@ -412,6 +416,7 @@ async function executeJobActionForJob(
return { jobId, ok: true, job: updated };
}
if (action === "rescore") {
if (job.status === "processing") {
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
jobId,
@ -439,11 +444,15 @@ async function executeJobActionForJob(
return rawProfile as Record<string, unknown>;
})();
const { score, reason } = await scoreJobSuitability(job, profile);
const { score, reason, analysis } = await scoreJobSuitability(
job,
profile,
);
const updated = await jobsRepo.updateJob(job.id, {
suitabilityScore: score,
suitabilityReason: reason,
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
});
if (!updated) {
throw new AppError({
@ -454,6 +463,57 @@ async function executeJobActionForJob(
}
return { jobId, ok: true, job: updated };
}
if (action === "generate_cover_letter") {
if (job.status === "processing") {
throw badRequest(
`Cannot generate cover letter while job is processing`,
{ jobId, status: job.status },
);
}
const { generateCoverLetter } = await import(
"@server/services/cover-letter"
);
const resumeProfile = await (async () => {
const rawProfile = await getProfile();
if (
!rawProfile ||
typeof rawProfile !== "object" ||
Array.isArray(rawProfile)
) {
throw badRequest("Invalid resume profile format");
}
return rawProfile as Record<string, unknown>;
})();
const { getEffectiveSettings } = await import(
"@server/services/settings"
);
const effectiveSettings = await getEffectiveSettings();
const searchProfile = effectiveSettings.jobSearchProfile?.value ?? null;
const { coverLetter } = await generateCoverLetter(
job,
resumeProfile,
searchProfile,
);
const updated = await jobsRepo.updateJob(job.id, { coverLetter });
if (!updated) {
throw new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found",
});
}
return { jobId, ok: true, job: updated };
}
throw badRequest(`Unknown action: ${action}`);
} catch (error) {
const mapped = mapErrorForResult(error);
return {

View File

@ -70,6 +70,7 @@ describe.sequential("Manual jobs API routes", () => {
vi.mocked(scoreJobSuitability).mockResolvedValue({
score: 88,
reason: "Strong fit",
analysis: null,
});
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {

View File

@ -269,13 +269,14 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
throw new Error("Invalid resume profile format");
}
const profile = rawProfile as Record<string, unknown>;
const { score, reason } = await scoreJobSuitability(
const { score, reason, analysis } = await scoreJobSuitability(
processedJob,
profile,
);
await jobsRepo.updateJob(processedJob.id, {
suitabilityScore: score,
suitabilityReason: reason,
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
});
} catch (error) {
logger.warn("Manual job scoring failed", {

View File

@ -509,7 +509,9 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("No base resume selected");
expect(String(body.data.message)).toMatch(
/base resume|local resume|JOBOPS_LOCAL|reactive resume|api key|not configured/i,
);
});
// Note: Further validation tests require mocking getSetting and getResume

View File

@ -3,13 +3,12 @@ import { logger } from "@infra/logger";
import { isDemoMode } from "@server/config/demo";
import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm/service";
import { getProfile } from "@server/services/profile";
import {
getResume,
RxResumeAuthConfigError,
validateResumeSchema,
validateCredentials as validateRxResumeCredentials,
} from "@server/services/rxresume";
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
import { type Request, type Response, Router } from "express";
export const onboardingRouter = Router();
@ -80,39 +79,18 @@ function normalizeLlmProviderValue(
}
/**
* Validate that a base resume is configured and accessible via Reactive Resume.
* Validate that a base resume is available (local JSON file, or Reactive Resume).
*/
async function validateResumeConfig(): Promise<ValidationResponse> {
try {
// Check if rxresumeBaseResumeId is configured
const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) {
return {
valid: false,
message:
"No base resume selected. Please select a resume from your RxResume account in Settings.",
};
}
// Verify the resume is accessible and valid
try {
const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") {
return {
valid: false,
message: "Selected resume is empty or invalid.",
};
}
const validated = await validateResumeSchema(resume.data);
if (!validated.ok) {
return { valid: false, message: validated.message };
}
const profile = await getProfile();
const validated = await validateResumeSchema(
profile as unknown as Record<string, unknown>,
);
if (validated.ok) {
return { valid: true, message: null };
}
return { valid: false, message: validated.message };
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
return {
@ -120,13 +98,6 @@ async function validateResumeConfig(): Promise<ValidationResponse> {
message: error.message,
};
}
const message =
error instanceof Error
? error.message
: "Failed to fetch resume from RxResume.";
return { valid: false, message };
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Resume validation failed.";
return { valid: false, message };

View File

@ -0,0 +1,124 @@
import { badRequest } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger";
import * as profilesRepo from "@server/repositories/profiles";
import { setSetting } from "@server/repositories/settings";
import { getProfile } from "@server/services/profile";
import { generateProfileFromResume } from "@server/services/profile-generator";
import { jobSearchProfileSchema } from "@shared/settings-registry";
import { type Request, type Response, Router } from "express";
export const profilesRouter = Router();
profilesRouter.get(
"/",
asyncRoute(async (_req: Request, res: Response) => {
const profiles = await profilesRepo.listProfiles();
return ok(res, profiles);
}),
);
profilesRouter.get(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
const profile = await profilesRepo.getProfileById(req.params.id);
if (!profile) {
return fail(res, badRequest("Profile not found"));
}
return ok(res, profile);
}),
);
profilesRouter.post(
"/",
asyncRoute(async (req: Request, res: Response) => {
const { name, data } = req.body;
if (!name || typeof name !== "string" || name.trim().length === 0) {
throw badRequest("Profile name is required");
}
const parsed = jobSearchProfileSchema.safeParse(data);
if (!parsed.success) {
throw badRequest("Invalid profile data", {
issues: parsed.error.issues,
});
}
const profile = await profilesRepo.createProfile({
name: name.trim(),
data: parsed.data,
});
return ok(res, profile);
}),
);
profilesRouter.patch(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
const { name, data } = req.body;
const updates: { name?: string; data?: typeof data } = {};
if (name !== undefined) {
if (typeof name !== "string" || name.trim().length === 0) {
throw badRequest("Profile name must be a non-empty string");
}
updates.name = name.trim();
}
if (data !== undefined) {
const parsed = jobSearchProfileSchema.safeParse(data);
if (!parsed.success) {
throw badRequest("Invalid profile data", {
issues: parsed.error.issues,
});
}
updates.data = parsed.data;
}
const profile = await profilesRepo.updateProfile(req.params.id, updates);
if (!profile) {
return fail(res, badRequest("Profile not found"));
}
return ok(res, profile);
}),
);
profilesRouter.delete(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
const deleted = await profilesRepo.deleteProfile(req.params.id);
if (!deleted) {
return fail(res, badRequest("Profile not found"));
}
return ok(res, { deleted: true });
}),
);
profilesRouter.post(
"/:id/activate",
asyncRoute(async (req: Request, res: Response) => {
const profile = await profilesRepo.getProfileById(req.params.id);
if (!profile) {
return fail(res, badRequest("Profile not found"));
}
await setSetting("activeProfileId", profile.id);
await setSetting("jobSearchProfile", JSON.stringify(profile.data));
return ok(res, { activated: true, profileId: profile.id });
}),
);
profilesRouter.post(
"/generate-from-resume",
asyncRoute(async (_req: Request, res: Response) => {
logger.info("Generating profile from resume");
const resumeProfile = await getProfile();
if (
!resumeProfile ||
typeof resumeProfile !== "object" ||
Array.isArray(resumeProfile)
) {
throw badRequest(
"No resume profile available. Configure Reactive Resume in Settings first.",
);
}
const generated = await generateProfileFromResume(
resumeProfile as Record<string, unknown>,
);
return ok(res, generated);
}),
);

View File

@ -336,6 +336,21 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
// Add suitability analysis column for rich fit assessment
`ALTER TABLE jobs ADD COLUMN suitability_analysis TEXT`,
// Add cover letter column for AI-generated cover letters
`ALTER TABLE jobs ADD COLUMN cover_letter TEXT`,
// Create search profiles table for multi-profile support
`CREATE TABLE IF NOT EXISTS search_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
// Add application tracking columns
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
@ -436,6 +451,8 @@ const migrations = [
closed_at INTEGER,
suitability_score REAL,
suitability_reason TEXT,
suitability_analysis TEXT,
cover_letter TEXT,
tailored_summary TEXT,
tailored_headline TEXT,
tailored_skills TEXT,
@ -457,7 +474,7 @@ const migrations = [
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
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, 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,
applied_at, created_at, updated_at
)
@ -468,7 +485,7 @@ const migrations = [
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
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, 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,
applied_at, created_at, updated_at
FROM jobs`,

View File

@ -93,6 +93,7 @@ export const jobs = sqliteTable("jobs", {
closedAt: integer("closed_at", { mode: "number" }),
suitabilityScore: real("suitability_score"),
suitabilityReason: text("suitability_reason"),
suitabilityAnalysis: text("suitability_analysis"),
tailoredSummary: text("tailored_summary"),
tailoredHeadline: text("tailored_headline"),
tailoredSkills: text("tailored_skills"),
@ -101,6 +102,7 @@ export const jobs = sqliteTable("jobs", {
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
.notNull()
.default(false),
coverLetter: text("cover_letter"),
sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"),
@ -249,6 +251,14 @@ export const jobChatRuns = sqliteTable(
}),
);
export const searchProfiles = sqliteTable("search_profiles", {
id: text("id").primaryKey(),
name: text("name").notNull(),
data: text("data").notNull(),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
@ -454,6 +464,8 @@ export type JobChatMessageRow = typeof jobChatMessages.$inferSelect;
export type NewJobChatMessageRow = typeof jobChatMessages.$inferInsert;
export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
export type SearchProfileRow = typeof searchProfiles.$inferSelect;
export type NewSearchProfileRow = typeof searchProfiles.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect;
export type NewSettingsRow = typeof settings.$inferInsert;
export type PostApplicationIntegrationRow =

View File

@ -87,7 +87,11 @@ describe("Sponsor Match Calculation", () => {
createJobs = jobsRepo.createJobs as ReturnType<typeof vi.fn>;
// Default mock implementations
scoreJobSuitability.mockResolvedValue({ score: 75, reason: "Good match" });
scoreJobSuitability.mockResolvedValue({
score: 75,
reason: "Good match",
analysis: null,
});
createJobs.mockResolvedValue({ created: 0, skipped: 0 });
updateJob.mockResolvedValue(undefined);

View File

@ -92,6 +92,56 @@ describe("discoverJobsStep", () => {
);
});
it("aligns JobSpy Indeed country to country-level search geography when settings disagree", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["indeed", "linkedin", "glassdoor"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Engineer",
employer: "ACME",
jobUrl: "https://example.com/job",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
searchCities: "UK",
jobspyCountryIndeed: "united states",
} as any);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([
["indeed", jobspyManifest as any],
["linkedin", jobspyManifest as any],
["glassdoor", jobspyManifest as any],
]),
availableSources: ["indeed", "linkedin", "glassdoor"],
} as any);
await discoverJobsStep({
mergedConfig: { ...baseConfig, sources: ["indeed", "linkedin"] },
});
expect(jobspyManifest.run).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
jobspyCountryIndeed: "united kingdom",
}),
}),
);
});
it("throws when all enabled sources fail", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");

View File

@ -11,6 +11,7 @@ import {
} from "@shared/location-support.js";
import { normalizeStringArray } from "@shared/normalize-string-array.js";
import {
inferCountryKeyFromSearchGeography,
matchesRequestedCity,
resolveSearchCities,
shouldApplyStrictCityFilter,
@ -106,12 +107,67 @@ export async function discoverJobsStep(args: {
.filter(Boolean);
}
const profileSetting = settings.jobSearchProfile;
if (profileSetting) {
try {
const profile = JSON.parse(profileSetting);
if (
Array.isArray(profile.targetRoles) &&
profile.targetRoles.length > 0
) {
const existingLower = new Set(searchTerms.map((t) => t.toLowerCase()));
for (const role of profile.targetRoles) {
if (
typeof role === "string" &&
role.trim() &&
!existingLower.has(role.trim().toLowerCase())
) {
searchTerms.push(role.trim());
existingLower.add(role.trim().toLowerCase());
}
}
logger.info("Augmented search terms with profile target roles", {
addedRoles: profile.targetRoles.length,
totalTerms: searchTerms.length,
});
}
} catch {
// malformed profile JSON, continue with existing terms
}
}
const geographyCountryKey = inferCountryKeyFromSearchGeography(
settings.searchCities,
settings.jobspyLocation,
);
const configuredIndeedKey = settings.jobspyCountryIndeed?.trim()
? normalizeCountryKey(settings.jobspyCountryIndeed)
: null;
if (
geographyCountryKey &&
configuredIndeedKey &&
geographyCountryKey !== configuredIndeedKey
) {
logger.warn(
"Indeed country setting disagrees with country-level search geography; aligning JobSpy and source routing to geography",
{
step: "discover-jobs",
geographyCountryKey,
jobspyCountryIndeed: configuredIndeedKey,
},
);
}
const selectedCountry = normalizeCountryKey(
geographyCountryKey ??
settings.jobspyCountryIndeed ??
settings.searchCities ??
settings.jobspyLocation ??
"united kingdom",
);
const effectiveJobspyCountryIndeed =
geographyCountryKey ?? settings.jobspyCountryIndeed;
const compatibleSources = args.mergedConfig.sources.filter((source) =>
isSourceAllowedForCountry(source, selectedCountry),
);
@ -188,6 +244,10 @@ export async function discoverJobsStep(args: {
),
) as Record<string, string | undefined>;
if (effectiveJobspyCountryIndeed !== undefined) {
filteredSettings.jobspyCountryIndeed = effectiveJobspyCountryIndeed;
}
const result = await manifest.run({
source: grouped.sources[0],
selectedSources: grouped.sources,

View File

@ -59,6 +59,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
score: 40,
reason: "Low fit",
analysis: null,
});
vi.mocked(visaSponsors.searchSponsors).mockResolvedValue([]);
vi.mocked(visaSponsors.calculateSponsorMatchSummary).mockReturnValue({
@ -103,6 +104,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
score: 50,
reason: "At threshold",
analysis: null,
});
await scoreJobsStep({ profile: {} });
@ -205,8 +207,16 @@ describe("scoreJobsStep auto-skip behavior", () => {
]);
vi.mocked(scorer.scoreJobSuitability)
.mockResolvedValueOnce({ score: 61, reason: "First score" })
.mockResolvedValueOnce({ score: 72, reason: "Second score" });
.mockResolvedValueOnce({
score: 61,
reason: "First score",
analysis: null,
})
.mockResolvedValueOnce({
score: 72,
reason: "Second score",
analysis: null,
});
const result = await scoreJobsStep({ profile: {} });

View File

@ -63,7 +63,10 @@ export async function scoreJobsStep(args: {
return;
}
const { score, reason } = await scoreJobSuitability(job, args.profile);
const { score, reason, analysis } = await scoreJobSuitability(
job,
args.profile,
);
if (args.shouldCancel?.()) return;
let sponsorMatchScore = 0;
@ -81,7 +84,6 @@ export async function scoreJobsStep(args: {
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
}
// Check if job should be auto-skipped based on score threshold
const shouldAutoSkip =
job.status !== "applied" &&
autoSkipThreshold !== null &&
@ -91,6 +93,7 @@ export async function scoreJobsStep(args: {
await jobsRepo.updateJob(job.id, {
suitabilityScore: score,
suitabilityReason: reason,
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
sponsorMatchScore,
sponsorMatchNames,
...(shouldAutoSkip ? { status: "skipped" } : {}),

View File

@ -17,7 +17,9 @@ import { db, schema } from "../db/index";
const { jobs } = schema;
function normalizeCreateJobInputForDedup(input: CreateJobInput): CreateJobInput {
function normalizeCreateJobInputForDedup(
input: CreateJobInput,
): CreateJobInput {
const jobUrl = canonicalizeJobUrl(input.jobUrl);
if (jobUrl === input.jobUrl) return input;
return { ...input, jobUrl };
@ -45,8 +47,7 @@ async function loadJobDedupIndexes(): Promise<{
const existingSourceJobKeySet = new Set(
rows
.filter(
(r) =>
r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
(r) => r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
)
.map((r) => sourceJobKey(r.source, String(r.sourceJobId))),
);
@ -54,7 +55,10 @@ async function loadJobDedupIndexes(): Promise<{
}
async function findJobByCanonicalUrl(canonical: string): Promise<Job | null> {
const [exact] = await db.select().from(jobs).where(eq(jobs.jobUrl, canonical));
const [exact] = await db
.select()
.from(jobs)
.where(eq(jobs.jobUrl, canonical));
if (exact) return mapRowToJob(exact);
const allRows = await db.select().from(jobs);
@ -547,6 +551,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
closedAt: row.closedAt ?? null,
suitabilityScore: row.suitabilityScore,
suitabilityReason: row.suitabilityReason,
suitabilityAnalysis: row.suitabilityAnalysis ?? null,
coverLetter: row.coverLetter ?? null,
tailoredSummary: row.tailoredSummary,
tailoredHeadline: row.tailoredHeadline ?? null,
tailoredSkills: row.tailoredSkills ?? null,

View File

@ -0,0 +1,95 @@
import { randomUUID } from "node:crypto";
import type {
CreateSearchProfileInput,
JobSearchProfile,
SearchProfile,
UpdateSearchProfileInput,
} from "@shared/types";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { searchProfiles } from "../db/schema";
function mapRow(row: typeof searchProfiles.$inferSelect): SearchProfile {
let data: JobSearchProfile;
try {
data = JSON.parse(row.data);
} catch {
data = {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
};
}
return {
id: row.id,
name: row.name,
data,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function listProfiles(): Promise<SearchProfile[]> {
const rows = await db.select().from(searchProfiles);
return rows.map(mapRow);
}
export async function getProfileById(
id: string,
): Promise<SearchProfile | null> {
const [row] = await db
.select()
.from(searchProfiles)
.where(eq(searchProfiles.id, id));
return row ? mapRow(row) : null;
}
export async function createProfile(
input: CreateSearchProfileInput,
): Promise<SearchProfile> {
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(searchProfiles).values({
id,
name: input.name,
data: JSON.stringify(input.data),
createdAt: now,
updatedAt: now,
});
const created = await getProfileById(id);
if (!created) throw new Error("Failed to create profile");
return created;
}
export async function updateProfile(
id: string,
input: UpdateSearchProfileInput,
): Promise<SearchProfile | null> {
const existing = await getProfileById(id);
if (!existing) return null;
const updates: Partial<typeof searchProfiles.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
if (input.name !== undefined) updates.name = input.name;
if (input.data !== undefined) updates.data = JSON.stringify(input.data);
await db.update(searchProfiles).set(updates).where(eq(searchProfiles.id, id));
return getProfileById(id);
}
export async function deleteProfile(id: string): Promise<boolean> {
const result = await db
.delete(searchProfiles)
.where(eq(searchProfiles.id, id));
return (result?.changes ?? 0) > 0;
}

View File

@ -0,0 +1,131 @@
import { logger } from "@infra/logger";
import type { Job, JobSearchProfile } from "@shared/types";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { resolveLlmModel } from "./modelSelection";
import { sanitizeProfileForPrompt } from "./scorer";
const COVER_LETTER_SCHEMA: JsonSchemaDefinition = {
name: "cover_letter",
schema: {
type: "object",
properties: {
coverLetter: {
type: "string",
description:
"Complete, ready-to-send cover letter (plain text, professional formatting with line breaks)",
},
},
required: ["coverLetter"],
additionalProperties: false,
},
};
interface CoverLetterResponse {
coverLetter: string;
}
function buildCoverLetterPrompt(
job: Job,
resumeProfile: Record<string, unknown>,
searchProfile: JobSearchProfile | null,
): string {
const parts: string[] = [];
parts.push(
"Write a professional, compelling cover letter for this job application.",
);
parts.push("");
parts.push("RULES:");
parts.push("- Keep it concise: 3-4 paragraphs, under 400 words");
parts.push(
"- Lead with what makes the candidate a strong fit for THIS specific role",
);
parts.push(
"- 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",
);
parts.push(
"- End with a strong closing that expresses interest in next steps",
);
parts.push("");
parts.push("=== JOB DETAILS ===");
parts.push(`Title: ${job.title}`);
parts.push(`Company: ${job.employer}`);
if (job.location) parts.push(`Location: ${job.location}`);
if (job.jobDescription) {
const desc =
job.jobDescription.length > 3000
? `${job.jobDescription.slice(0, 3000)}...`
: job.jobDescription;
parts.push(`Description:\n${desc}`);
}
parts.push("");
parts.push("=== CANDIDATE RESUME ===");
parts.push(JSON.stringify(resumeProfile, null, 2));
if (searchProfile) {
parts.push("");
parts.push("=== CANDIDATE PREFERENCES ===");
if (searchProfile.aboutMe) {
parts.push(`About: ${searchProfile.aboutMe}`);
}
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(", ")}`,
);
}
}
return parts.join("\n");
}
export async function generateCoverLetter(
job: Job,
resumeProfile: Record<string, unknown>,
searchProfile: JobSearchProfile | null,
): Promise<{ coverLetter: string }> {
const model = await resolveLlmModel("scoring");
const prompt = buildCoverLetterPrompt(
job,
sanitizeProfileForPrompt(resumeProfile),
searchProfile,
);
const llm = new LlmService();
const result = await llm.callJson<CoverLetterResponse>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: COVER_LETTER_SCHEMA,
maxRetries: 1,
jobId: job.id,
});
if (!result.success) {
logger.error("Cover letter generation failed", {
jobId: job.id,
error: result.error,
});
throw new Error(`Cover letter generation failed: ${result.error}`);
}
const letter = result.data.coverLetter;
if (!letter || typeof letter !== "string") {
throw new Error("LLM returned empty cover letter");
}
return { coverLetter: letter };
}

View File

@ -0,0 +1,173 @@
import { logger } from "@infra/logger";
import type { JobSearchProfile } from "@shared/types";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { resolveLlmModel } from "./modelSelection";
import { sanitizeProfileForPrompt } from "./scorer";
const PROFILE_GENERATION_SCHEMA: JsonSchemaDefinition = {
name: "job_search_profile",
schema: {
type: "object",
properties: {
targetRoles: {
type: "array",
items: { type: "string" },
description:
"3-8 job titles the candidate would be a good fit for, based on their experience",
},
experienceLevel: {
type: "string",
description:
"Experience level (e.g. 'Junior', 'Mid-level', 'Senior', 'Lead', 'Principal')",
},
mustHaveSkills: {
type: "array",
items: { type: "string" },
description:
"Core technical and professional skills evident from the resume",
},
niceToHaveSkills: {
type: "array",
items: { type: "string" },
description: "Secondary skills or technologies mentioned in the resume",
},
dealBreakers: {
type: "array",
items: { type: "string" },
description:
"Types of roles that would NOT match this candidate (inferred from their specialisation)",
},
preferredWorkArrangement: {
type: "array",
items: { type: "string" },
description: "Work arrangement preferences (remote, hybrid, onsite)",
},
preferredLocations: {
type: "array",
items: { type: "string" },
description: "Locations inferred from resume (current city, country)",
},
minimumSalary: {
type: "string",
description: "Leave empty string — cannot be inferred from a resume",
},
industriesToTarget: {
type: "array",
items: { type: "string" },
description: "Industries that match the candidate's experience",
},
industriesToAvoid: {
type: "array",
items: { type: "string" },
description:
"Industries that clearly don't match (leave empty if unclear)",
},
aboutMe: {
type: "string",
description:
"A 2-3 sentence summary of who this person is and what they're looking for, written in first person",
},
},
required: [
"targetRoles",
"experienceLevel",
"mustHaveSkills",
"niceToHaveSkills",
"dealBreakers",
"preferredWorkArrangement",
"preferredLocations",
"minimumSalary",
"industriesToTarget",
"industriesToAvoid",
"aboutMe",
],
additionalProperties: false,
},
};
function buildGenerationPrompt(resumeProfile: Record<string, unknown>): string {
const parts: string[] = [];
parts.push(
"Analyse this resume and create a job search profile for the candidate.",
);
parts.push("");
parts.push("RULES:");
parts.push(
"- Infer target roles from their actual job titles and experience, not aspirational ones",
);
parts.push(
"- Extract real skills from the resume — do not invent skills not evidenced",
);
parts.push(
"- For deal-breakers, think about what kinds of roles would be a BAD match",
);
parts.push(
" (e.g. if they're an automation tester, 'Full-stack development roles' is a deal-breaker)",
);
parts.push("- Infer location from resume if available");
parts.push("- Leave minimumSalary as empty string (cannot be inferred)");
parts.push(
"- Write aboutMe in first person as if the candidate is describing themselves",
);
parts.push("- Be specific and practical, not generic");
parts.push("");
parts.push("=== RESUME ===");
parts.push(JSON.stringify(resumeProfile, null, 2));
return parts.join("\n");
}
export async function generateProfileFromResume(
resumeProfile: Record<string, unknown>,
): Promise<JobSearchProfile> {
const model = await resolveLlmModel("scoring");
const prompt = buildGenerationPrompt(sanitizeProfileForPrompt(resumeProfile));
const llm = new LlmService();
const result = await llm.callJson<JobSearchProfile>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: PROFILE_GENERATION_SCHEMA,
maxRetries: 1,
});
if (!result.success) {
logger.error("Profile generation from resume failed", {
error: result.error,
});
throw new Error(`Profile generation failed: ${result.error}`);
}
const data = result.data;
return {
targetRoles: Array.isArray(data.targetRoles) ? data.targetRoles : [],
experienceLevel:
typeof data.experienceLevel === "string" ? data.experienceLevel : "",
mustHaveSkills: Array.isArray(data.mustHaveSkills)
? data.mustHaveSkills
: [],
niceToHaveSkills: Array.isArray(data.niceToHaveSkills)
? data.niceToHaveSkills
: [],
dealBreakers: Array.isArray(data.dealBreakers) ? data.dealBreakers : [],
preferredWorkArrangement: Array.isArray(data.preferredWorkArrangement)
? data.preferredWorkArrangement
: [],
preferredLocations: Array.isArray(data.preferredLocations)
? data.preferredLocations
: [],
minimumSalary:
typeof data.minimumSalary === "string" ? data.minimumSalary : "",
industriesToTarget: Array.isArray(data.industriesToTarget)
? data.industriesToTarget
: [],
industriesToAvoid: Array.isArray(data.industriesToAvoid)
? data.industriesToAvoid
: [],
aboutMe: typeof data.aboutMe === "string" ? data.aboutMe : "",
};
}

View File

@ -1,3 +1,6 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearProfileCache, getProfile } from "./profile";
@ -19,23 +22,42 @@ vi.mock("./rxresume", () => ({
import { getSetting } from "../repositories/settings";
import { getResume, RxResumeAuthConfigError } from "./rxresume";
function mockRxResumeOnlyFlow(resumeId: string) {
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return null;
if (key === "rxresumeMode") return "v5";
if (
key === "rxresumeBaseResumeId" ||
key === "rxresumeBaseResumeIdV5" ||
key === "rxresumeBaseResumeIdV4"
) {
return resumeId;
}
return null;
});
}
describe("getProfile", () => {
beforeEach(() => {
vi.resetAllMocks();
clearProfileCache();
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
});
it("should throw an error if rxresumeBaseResumeId is not configured", async () => {
vi.mocked(getSetting).mockResolvedValue(null);
it("should throw an error if no local file and rxresumeBaseResumeId is not configured", async () => {
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return null;
return null;
});
await expect(getProfile()).rejects.toThrow(
"Base resume not configured. Please select a base resume from your RxResume account in Settings.",
/Base resume not configured|JOBOPS_LOCAL_RESUME_PATH|local resume path/i,
);
});
it("should fetch profile from Reactive Resume when configured", async () => {
const mockResumeData = { basics: { name: "Test User" } };
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
@ -43,15 +65,13 @@ describe("getProfile", () => {
const profile = await getProfile();
expect(getSetting).toHaveBeenCalledWith("rxresumeMode");
expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId");
expect(getResume).toHaveBeenCalledWith("test-resume-id");
expect(profile).toEqual(mockResumeData);
});
it("should cache the profile and not refetch on subsequent calls", async () => {
const mockResumeData = { basics: { name: "Test User" } };
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
@ -60,15 +80,12 @@ describe("getProfile", () => {
await getProfile();
await getProfile();
// The helper reads mode + legacy/per-mode resume-id settings each call.
expect(getSetting).toHaveBeenCalledTimes(8);
// But getResume should only be called once due to caching
expect(getResume).toHaveBeenCalledTimes(1);
});
it("should refetch when forceRefresh is true", async () => {
const mockResumeData = { basics: { name: "Test User" } };
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
@ -86,7 +103,7 @@ describe("getProfile", () => {
});
it("should throw user-friendly error on credential issues", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockRejectedValue(
new (RxResumeAuthConfigError as unknown as new () => Error)(),
);
@ -97,7 +114,7 @@ describe("getProfile", () => {
});
it("should throw error if resume data is empty", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: null,
@ -107,4 +124,41 @@ describe("getProfile", () => {
"Resume data is empty or invalid",
);
});
it("loads profile from localResumeProfilePath when set", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-"));
const filePath = join(dir, "resume.json");
const mockResumeData = { basics: { name: "Local User" } };
await writeFile(filePath, JSON.stringify(mockResumeData), "utf8");
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return filePath;
return null;
});
const profile = await getProfile();
expect(profile).toEqual(mockResumeData);
expect(getResume).not.toHaveBeenCalled();
});
it("prefers JOBOPS_LOCAL_RESUME_PATH over RxResume", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-env-"));
const filePath = join(dir, "resume.json");
await writeFile(
filePath,
JSON.stringify({ basics: { name: "Env User" } }),
"utf8",
);
process.env.JOBOPS_LOCAL_RESUME_PATH = filePath;
mockRxResumeOnlyFlow("ignored-id");
vi.mocked(getResume).mockResolvedValue({
id: "ignored-id",
data: { basics: { name: "Remote User" } },
} as any);
const profile = await getProfile();
expect(profile.basics?.name).toBe("Env User");
expect(getResume).not.toHaveBeenCalled();
});
});

View File

@ -1,4 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { logger } from "@infra/logger";
import { getSetting } from "@server/repositories/settings";
import type { ResumeProfile } from "@shared/types";
import { getResume, RxResumeAuthConfigError } from "./rxresume";
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
@ -6,22 +9,97 @@ import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
let cachedProfile: ResumeProfile | null = null;
let cachedResumeId: string | null = null;
/** Cache key is absolute path + file mtime (ms). */
let cachedLocalSourceKey: string | null = null;
let cachedLocalProfile: ResumeProfile | null = null;
/**
* Get the base resume profile from RxResume.
* Prefer `JOBOPS_LOCAL_RESUME_PATH`, then `localResumeProfilePath` setting.
* Relative paths resolve against `process.cwd()` (orchestrator working directory).
*/
export async function resolveLocalResumeFilePath(): Promise<string | null> {
const envPath = process.env.JOBOPS_LOCAL_RESUME_PATH?.trim();
const raw = envPath ?? (await getSetting("localResumeProfilePath"))?.trim();
if (!raw) return null;
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
}
async function loadProfileFromLocalFile(
absolutePath: string,
forceRefresh: boolean,
): Promise<ResumeProfile> {
let mtimeKey = "unknown";
try {
const st = await stat(absolutePath);
mtimeKey = String(st.mtimeMs);
} catch {
mtimeKey = "missing";
}
const cacheKey = `${absolutePath}:${mtimeKey}`;
if (
!forceRefresh &&
cacheKey === cachedLocalSourceKey &&
cachedLocalProfile !== null
) {
return cachedLocalProfile;
}
let raw: string;
try {
raw = await readFile(absolutePath, "utf8");
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Cannot read local resume file at ${absolutePath}. ${detail}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
`Local resume file at ${absolutePath} is not valid JSON (Reactive Resume export shape expected).`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(
`Local resume file at ${absolutePath} must be a single JSON object.`,
);
}
cachedLocalSourceKey = cacheKey;
cachedLocalProfile = parsed as ResumeProfile;
logger.info("Profile loaded from local JSON file", {
path: absolutePath,
});
return cachedLocalProfile;
}
/**
* Get the base resume profile: local JSON file (if configured) first, else Reactive Resume API.
*
* Local sources (in order): `JOBOPS_LOCAL_RESUME_PATH` env, then `localResumeProfilePath` setting.
* Otherwise requires `rxresumeBaseResumeId` and RxResume credentials.
*
* Requires rxresumeBaseResumeId to be configured in settings.
* Results are cached until clearProfileCache() is called.
*
* @param forceRefresh Force reload from API.
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
* @param forceRefresh Force reload from disk or API.
*/
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
const localPath = await resolveLocalResumeFilePath();
if (localPath) {
return loadProfileFromLocalFile(localPath, forceRefresh);
}
const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) {
throw new Error(
"Base resume not configured. Please select a base resume from your RxResume account in Settings.",
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or local resume path in Settings, or select a base resume from Reactive Resume.",
);
}
@ -78,4 +156,6 @@ export async function getPersonName(): Promise<string> {
export function clearProfileCache(): void {
cachedProfile = null;
cachedResumeId = null;
cachedLocalSourceKey = null;
cachedLocalProfile = null;
}

View File

@ -3,7 +3,7 @@
*/
import { logger } from "@infra/logger";
import type { Job } from "@shared/types";
import type { Job, JobSearchProfile, SuitabilityAnalysis } from "@shared/types";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { stripMarkdownCodeFences } from "./llm/utils/json";
@ -11,15 +11,17 @@ import { resolveLlmModel } from "./modelSelection";
import { getEffectiveSettings } from "./settings";
interface SuitabilityResult {
score: number; // 0-100
reason: string; // Explanation
score: number;
reason: string;
analysis: SuitabilityAnalysis | null;
}
type ScoringPreferences = {
instructions: string;
jobSearchProfile: JobSearchProfile | null;
};
/** JSON schema for suitability scoring response */
/** JSON schema for suitability scoring response (enhanced with analysis) */
const SCORING_SCHEMA: JsonSchemaDefinition = {
name: "job_suitability_score",
schema: {
@ -33,12 +35,59 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
type: "string",
description: "Brief 1-2 sentence explanation of the score",
},
roleTypeMatch: {
type: "integer",
description:
"How well the job role type matches what the candidate wants (0-100)",
},
required: ["score", "reason"],
strengths: {
type: "array",
items: { type: "string" },
description:
"2-4 specific strengths where the candidate matches this job well",
},
gaps: {
type: "array",
items: { type: "string" },
description:
"1-3 specific skills or requirements the candidate is missing for this job",
},
suggestions: {
type: "array",
items: { type: "string" },
description:
"1-3 actionable suggestions to improve candidacy for this type of role",
},
dealBreakerHits: {
type: "array",
items: { type: "string" },
description:
"Any deal-breakers from the candidate profile that this job triggers",
},
},
required: [
"score",
"reason",
"roleTypeMatch",
"strengths",
"gaps",
"suggestions",
"dealBreakerHits",
],
additionalProperties: false,
},
};
interface ScoringLlmResponse {
score: number;
reason: string;
roleTypeMatch?: number;
strengths?: string[];
gaps?: string[];
suggestions?: string[];
dealBreakerHits?: string[];
}
/**
* Check if a job's salary field is missing/empty.
* Returns true for null, empty string, or whitespace-only strings.
@ -80,6 +129,23 @@ function applySalaryPenalty(
return { score: adjustedScore, reason: adjustedReason, penaltyApplied: true };
}
function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null {
if (!data.strengths && !data.gaps && !data.suggestions) return null;
return {
roleTypeMatch:
typeof data.roleTypeMatch === "number"
? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch)))
: 50,
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
: [],
};
}
/**
* Score a job's suitability based on profile and job description.
* Includes retry logic for when AI returns garbage responses.
@ -93,12 +159,16 @@ export async function scoreJobSuitability(
getEffectiveSettings(),
]);
const jobSearchProfile = settings.jobSearchProfile?.value ?? null;
const hasProfile = jobSearchProfile && hasNonEmptyProfile(jobSearchProfile);
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
instructions: settings.scoringInstructions?.value ?? "",
jobSearchProfile: hasProfile ? jobSearchProfile : null,
});
const llm = new LlmService();
const result = await llm.callJson<{ score: number; reason: string }>({
const result = await llm.callJson<ScoringLlmResponse>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: SCORING_SCHEMA,
@ -120,10 +190,9 @@ export async function scoreJobSuitability(
});
}
const { score, reason } = result.data;
const data = result.data;
// Validate we got a reasonable response
if (typeof score !== "number" || Number.isNaN(score)) {
if (typeof data.score !== "number" || Number.isNaN(data.score)) {
logger.error("Invalid score in AI response, using mock scoring", {
jobId: job.id,
});
@ -133,10 +202,10 @@ export async function scoreJobSuitability(
});
}
const clampedScore = Math.min(100, Math.max(0, Math.round(score)));
const clampedReason = reason || "No explanation provided";
const clampedScore = Math.min(100, Math.max(0, Math.round(data.score)));
const clampedReason = data.reason || "No explanation provided";
const analysis = extractAnalysis(data);
// Apply salary penalty if enabled
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty.value,
@ -145,9 +214,20 @@ export async function scoreJobSuitability(
return {
score: penaltyResult.score,
reason: penaltyResult.reason,
analysis,
};
}
function hasNonEmptyProfile(p: JobSearchProfile): boolean {
return (
p.targetRoles.length > 0 ||
p.mustHaveSkills.length > 0 ||
p.dealBreakers.length > 0 ||
p.aboutMe.trim().length > 0 ||
p.experienceLevel.trim().length > 0
);
}
/**
* Robustly parse JSON from AI-generated content.
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
@ -161,44 +241,33 @@ export function parseJsonFromContent(
const originalContent = content;
let candidate = content.trim();
// Step 1: Remove markdown code fences (with or without language specifier)
candidate = stripMarkdownCodeFences(candidate);
// Step 2: Try to extract JSON object if there's surrounding text
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
if (jsonMatch) {
candidate = jsonMatch[0];
}
// Step 3: Try direct parse first
try {
return JSON.parse(candidate);
} catch {
// Continue with sanitization
}
// Step 4: Fix common JSON issues
let sanitized = candidate;
// Remove JavaScript-style comments (// and /* */)
sanitized = sanitized.replace(/\/\/[^\n]*/g, "");
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, "");
// Remove trailing commas before } or ]
sanitized = sanitized.replace(/,\s*([\]}])/g, "$1");
// Fix unquoted keys: word: -> "word":
// Be more careful - only match at start of object or after comma
sanitized = sanitized.replace(
/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g,
'$1"$2":',
);
// Fix single quotes to double quotes
sanitized = sanitized.replace(/'/g, '"');
// Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON)
// First, let's normalize the string - escape actual newlines inside strings
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed to fix broken JSON from AI
const controlCharsRegex = /[\x00-\x1F\x7F]/g;
sanitized = sanitized.replace(controlCharsRegex, (match) => {
@ -208,15 +277,12 @@ export function parseJsonFromContent(
return "";
});
// Step 5: Try parsing the sanitized version
try {
return JSON.parse(sanitized);
} catch {
// Continue with more aggressive extraction
}
// Step 6: Even more aggressive - try to rebuild a minimal valid JSON
// by extracting just the score and reason values
const scoreMatch = originalContent.match(
/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i,
);
@ -238,7 +304,6 @@ export function parseJsonFromContent(
return { score, reason };
}
// Log the failure with full content for debugging
logger.error("Failed to parse AI response", {
jobId: jobId || "unknown",
rawSample: originalContent.substring(0, 500),
@ -253,16 +318,59 @@ function buildScoringPrompt(
profile: Record<string, unknown>,
preferences: ScoringPreferences,
): string {
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
const p = preferences.jobSearchProfile;
const hasProfilePrefs = p !== null;
SCORING CRITERIA:
const profilePrefsBlock = hasProfilePrefs
? `
CANDIDATE JOB SEARCH PREFERENCES (CRITICAL - weigh these heavily):
Target Roles: ${p.targetRoles.length > 0 ? p.targetRoles.join(", ") : "Not specified"}
Experience Level: ${p.experienceLevel || "Not specified"}
Must-Have Skills: ${p.mustHaveSkills.length > 0 ? p.mustHaveSkills.join(", ") : "Not specified"}
Nice-to-Have Skills: ${p.niceToHaveSkills.length > 0 ? p.niceToHaveSkills.join(", ") : "Not specified"}
Deal-Breakers (score 0-15 if triggered): ${p.dealBreakers.length > 0 ? p.dealBreakers.join(", ") : "None"}
Preferred Work Arrangement: ${p.preferredWorkArrangement.length > 0 ? p.preferredWorkArrangement.join(", ") : "Any"}
Preferred Locations: ${p.preferredLocations.length > 0 ? p.preferredLocations.join(", ") : "Any"}
Minimum Salary: ${p.minimumSalary || "Not specified"}
Target Industries: ${p.industriesToTarget.length > 0 ? p.industriesToTarget.join(", ") : "Any"}
Industries to Avoid: ${p.industriesToAvoid.length > 0 ? p.industriesToAvoid.join(", ") : "None"}
About the Candidate: ${p.aboutMe || "Not provided"}`
: "";
const dealBreakerRules = hasProfilePrefs
? `
DEAL-BREAKER RULES (STRICTLY ENFORCE):
- If the job's primary role type fundamentally mismatches the candidate's target roles, score MUST be 0-20.
Example: If candidate wants "automation tester" roles, a "Full Stack Developer" job should score very low
even if the description mentions testing tools. The JOB TITLE and PRIMARY RESPONSIBILITIES matter most.
- If any deal-breaker keywords appear in the job title or core requirements, score MUST be 0-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.`
: "";
const scoringCriteria = hasProfilePrefs
? `SCORING CRITERIA (with candidate preferences):
- Role type alignment with target roles: 0-35 points (MOST IMPORTANT - is this the KIND of job they want?)
- Skills match (must-haves weighted 3x, nice-to-haves 1x): 0-25 points
- Experience level match: 0-15 points
- Location/remote work alignment with preferences: 0-10 points
- Industry/domain fit: 0-10 points
- Career growth and salary alignment: 0-5 points`
: `SCORING CRITERIA:
- Skills match (technologies, frameworks, languages): 0-30 points
- Experience level match: 0-25 points
- Location/remote work alignment: 0-15 points
- Industry/domain fit: 0-15 points
- Career growth potential: 0-15 points
- Career growth potential: 0-15 points`;
CANDIDATE PROFILE:
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
${scoringCriteria}
${dealBreakerRules}
${profilePrefsBlock}
CANDIDATE RESUME:
${JSON.stringify(profile, null, 2)}
JOB LISTING:
@ -286,13 +394,20 @@ ${
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
REQUIRED FORMAT (exactly this structure):
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>"}
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>", "roleTypeMatch": <integer 0-100>, "strengths": ["<strength 1>", "<strength 2>"], "gaps": ["<gap 1>"], "suggestions": ["<suggestion 1>"], "dealBreakerHits": []}
RULES FOR ANALYSIS FIELDS:
- "roleTypeMatch": How well does this job's role TYPE match what the candidate wants? 100 = perfect role type, 0 = completely wrong type of work.
- "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.
EXAMPLE VALID RESPONSE:
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
{"score": 25, "reason": "This is a full-stack developer role but the candidate is targeting automation testing positions. The mention of Playwright in the description is minor and not the core focus.", "roleTypeMatch": 15, "strengths": ["Has Playwright experience mentioned in the job description", "Located in the same city"], "gaps": ["No React/Node.js full-stack experience", "Job requires 3+ years of backend development"], "suggestions": ["If interested in full-stack, build portfolio projects with React and Node.js", "Consider SDET roles that bridge testing and development"], "dealBreakerHits": ["Role type mismatch: Full Stack Developer vs target of Automation Tester"]}`;
}
function sanitizeProfileForPrompt(
export function sanitizeProfileForPrompt(
profile: Record<string, unknown>,
): Record<string, unknown> {
const p = profile as {
@ -328,7 +443,6 @@ async function mockScore(
job: Job,
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
): Promise<SuitabilityResult> {
// Simple keyword-based scoring as fallback
const jd = (job.jobDescription || "").toLowerCase();
const title = job.title.toLowerCase();
@ -368,12 +482,12 @@ async function mockScore(
const baseReason = "Scored using keyword matching (API key not configured)";
// Apply salary penalty if enabled
const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);
return {
score: penaltyResult.score,
reason: penaltyResult.reason,
analysis: null,
};
}
@ -384,15 +498,25 @@ export async function scoreAndRankJobs(
jobs: Job[],
profile: Record<string, unknown>,
): Promise<
Array<Job & { suitabilityScore: number; suitabilityReason: string }>
Array<
Job & {
suitabilityScore: number;
suitabilityReason: string;
suitabilityAnalysis: string | null;
}
>
> {
const scoredJobs = await Promise.all(
jobs.map(async (job) => {
const { score, reason } = await scoreJobSuitability(job, profile);
const { score, reason, analysis } = await scoreJobSuitability(
job,
profile,
);
return {
...job,
suitabilityScore: score,
suitabilityReason: reason,
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : null,
};
}),
);

View File

@ -92,35 +92,38 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
});
let profile: Record<string, unknown> = {};
try {
profile = (await getProfile()) as Record<string, unknown>;
} catch (error) {
logger.warn("Failed to load base resume profile for settings (primary)", {
error,
});
if (rxresumeBaseResumeId) {
try {
const resume = await getResume(rxresumeBaseResumeId);
if (resume.data && typeof resume.data === "object") {
profile = resume.data as Record<string, unknown>;
}
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
} catch (rxError) {
if (rxError instanceof RxResumeAuthConfigError) {
logger.warn(
"Reactive Resume credentials missing during settings load",
{
resumeId: rxresumeBaseResumeId,
error,
error: rxError,
},
);
} else {
logger.warn("Failed to load Reactive Resume base resume for settings", {
logger.warn(
"Failed to load Reactive Resume base resume for settings",
{
resumeId: rxresumeBaseResumeId,
error,
});
error: rxError,
},
);
}
}
}
if (Object.keys(profile).length === 0) {
profile = await getProfile().catch((error) => {
logger.warn("Failed to load base resume profile for settings", { error });
return {};
});
}
const envSettings = await getEnvSettingsData(overrides);
@ -217,5 +220,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
// Always expose the effective base resume id for the active RxResume mode.
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
result.localResumeFileConfigured =
Boolean(process.env.JOBOPS_LOCAL_RESUME_PATH?.trim()) ||
Boolean((overrides.localResumeProfilePath ?? "").trim());
return result as AppSettings;
}

View File

@ -63,6 +63,11 @@ export default defineConfig({
},
server: {
port: 5173,
// Extractors (e.g. startup.jobs / Apify-style KV) write under ./storage during
// pipeline runs; watching those files causes spurious full page reloads.
watch: {
ignored: [path.resolve(__dirname, "storage")],
},
proxy: {
"/api": {
target: "http://localhost:3001",

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
inferCountryKeyFromSearchGeography,
matchesRequestedCity,
parseSearchCitiesSetting,
resolveSearchCities,
@ -64,6 +65,18 @@ describe("search-cities", () => {
).toEqual([]);
});
it("infers country key from geography when a token is a supported country", () => {
expect(inferCountryKeyFromSearchGeography("UK", null)).toBe(
"united kingdom",
);
expect(inferCountryKeyFromSearchGeography("London|UK", null)).toBe(
"united kingdom",
);
expect(inferCountryKeyFromSearchGeography(null, "Canada")).toBe("canada");
expect(inferCountryKeyFromSearchGeography("Leeds", null)).toBeNull();
expect(inferCountryKeyFromSearchGeography(null, null)).toBeNull();
});
it("applies strict filter only when city differs from country", () => {
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);

View File

@ -1,4 +1,9 @@
import { normalizeCountryKey } from "./location-support.js";
import {
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "./location-support.js";
const supportedCountryKeySet = new Set(SUPPORTED_COUNTRY_KEYS);
const LOCATION_ALIASES: Record<string, string> = {
uk: "united kingdom",
@ -14,6 +19,23 @@ export function normalizeLocationToken(
return LOCATION_ALIASES[normalized] ?? normalized;
}
/**
* If search geography includes a supported country token (e.g. "UK", "Canada"),
* returns its normalized country key; otherwise null (e.g. "London" only).
*/
export function inferCountryKeyFromSearchGeography(
searchCities?: string | null,
jobspyLocation?: string | null,
): string | null {
const raw = searchCities?.trim() || jobspyLocation?.trim();
if (!raw) return null;
for (const token of parseSearchCitiesSetting(raw)) {
const key = normalizeCountryKey(token);
if (supportedCountryKeySet.has(key)) return key;
}
return null;
}
export function parseSearchCitiesSetting(
value: string | null | undefined,
): string[] {

View File

@ -4,6 +4,7 @@ import {
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
type ChatStyleLanguageMode,
type ChatStyleManualLanguage,
type JobSearchProfile,
type ResumeProjectsSettings,
} from "./types/settings";
@ -130,14 +131,58 @@ const parseChatStyleManualLanguageOrNull = createEnumParser(
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES);
export const jobSearchProfileSchema = z.object({
targetRoles: z.array(z.string().trim().min(1).max(200)).max(20),
experienceLevel: z.string().trim().max(50),
mustHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
niceToHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
dealBreakers: z.array(z.string().trim().min(1).max(200)).max(50),
preferredWorkArrangement: z.array(z.string().trim().min(1).max(50)).max(5),
preferredLocations: z.array(z.string().trim().min(1).max(200)).max(20),
minimumSalary: z.string().trim().max(100),
industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20),
industriesToAvoid: z.array(z.string().trim().min(1).max(200)).max(20),
aboutMe: z.string().trim().max(4000),
});
export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
const DEFAULT_JOB_SEARCH_PROFILE: JobSearchProfile = {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
};
export const settingsRegistry = {
// --- Typed Settings ---
jobSearchProfile: {
kind: "typed" as const,
schema: jobSearchProfileSchema,
default: (): JobSearchProfile => DEFAULT_JOB_SEARCH_PROFILE,
parse: (raw: string | undefined): JobSearchProfile | null => {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
serialize: (value: JobSearchProfile | null | undefined): string | null => {
return value ? JSON.stringify(value) : null;
},
},
model: {
kind: "typed" as const,
schema: z.string().trim().max(200),
@ -535,6 +580,10 @@ export const settingsRegistry = {
},
// --- Simple Strings ---
activeProfileId: {
kind: "string" as const,
schema: z.string().trim().max(200),
},
rxresumeBaseResumeId: {
kind: "string" as const,
schema: z.string().trim().max(200),
@ -560,6 +609,11 @@ export const settingsRegistry = {
z.string().trim().url().max(2000).nullable(),
),
},
/** Server path to Reactive Resume JSON export; used when RxResume API is not available. */
localResumeProfilePath: {
kind: "string" as const,
schema: z.string().trim().max(4000),
},
ukvisajobsEmail: {
kind: "string" as const,
envKey: "UKVISAJOBS_EMAIL",

View File

@ -30,6 +30,8 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
closedAt: null,
suitabilityScore: 90,
suitabilityReason: "Strong fit",
suitabilityAnalysis: null,
coverLetter: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
@ -125,6 +127,35 @@ export const createResumeProjectCatalogItem = (
export const createAppSettings = (
overrides: Partial<AppSettings> = {},
): AppSettings => ({
jobSearchProfile: {
value: {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
},
default: {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
},
override: null,
},
model: { value: "gpt-4o", default: "gpt-4o", override: null },
modelScorer: { value: "gpt-4o", override: null },
modelTailoring: { value: "gpt-4o", override: null },
@ -147,6 +178,7 @@ export const createAppSettings = (
},
override: null,
},
activeProfileId: null,
rxresumeBaseResumeId: null,
rxresumeBaseResumeIdV4: null,
rxresumeBaseResumeIdV5: null,
@ -213,6 +245,7 @@ export const createAppSettings = (
rxresumeApiKeyHint: null,
rxresumeEmail: null,
rxresumeUrl: null,
localResumeProfilePath: null,
rxresumePasswordHint: null,
basicAuthUser: null,
basicAuthPasswordHint: null,
@ -222,6 +255,7 @@ export const createAppSettings = (
adzunaAppKeyHint: null,
webhookSecretHint: null,
basicAuthActive: false,
localResumeFileConfigured: false,
backupEnabled: { value: false, default: false, override: null },
backupHour: { value: 3, default: 3, override: null },
backupMaxCount: { value: 7, default: 7, override: null },

View File

@ -148,12 +148,14 @@ export interface Job {
closedAt: number | null;
suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation
suitabilityAnalysis: string | null; // JSON-encoded SuitabilityAnalysis
tailoredSummary: string | null; // Generated resume summary
tailoredHeadline: string | null; // Generated resume headline
tailoredSkills: string | null; // Generated resume skills (JSON)
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
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)
@ -305,6 +307,7 @@ export interface UpdateJobInput {
jobDescription?: string | null;
suitabilityScore?: number;
suitabilityReason?: string;
suitabilityAnalysis?: string;
tailoredSummary?: string;
tailoredHeadline?: string;
tailoredSkills?: string;
@ -312,6 +315,7 @@ export interface UpdateJobInput {
pdfPath?: string;
tracerLinksEnabled?: boolean;
appliedAt?: string;
coverLetter?: string | null;
sponsorMatchScore?: number;
sponsorMatchNames?: string;
}

View File

@ -42,11 +42,15 @@ export interface JobsRevisionResponse {
statusFilter: string | null;
}
export type JobAction = "skip" | "move_to_ready" | "rescore";
export type JobAction =
| "skip"
| "move_to_ready"
| "rescore"
| "generate_cover_letter";
export type JobActionRequest =
| {
action: "skip" | "rescore";
action: "skip" | "rescore" | "generate_cover_letter";
jobIds: string[];
}
| {

View File

@ -1,3 +1,43 @@
export interface JobSearchProfile {
targetRoles: string[];
experienceLevel: string;
mustHaveSkills: string[];
niceToHaveSkills: string[];
dealBreakers: string[];
preferredWorkArrangement: string[];
preferredLocations: string[];
minimumSalary: string;
industriesToTarget: string[];
industriesToAvoid: string[];
aboutMe: string;
}
export interface SearchProfile {
id: string;
name: string;
data: JobSearchProfile;
createdAt: string;
updatedAt: string;
}
export interface CreateSearchProfileInput {
name: string;
data: JobSearchProfile;
}
export interface UpdateSearchProfileInput {
name?: string;
data?: JobSearchProfile;
}
export interface SuitabilityAnalysis {
roleTypeMatch: number;
strengths: string[];
gaps: string[];
suggestions: string[];
dealBreakerHits: string[];
}
export interface ResumeProjectCatalogItem {
id: string;
name: string;
@ -144,6 +184,7 @@ export type ModelResolved = { value: string; override: string | null };
export interface AppSettings {
// Typed settings (Resolved):
jobSearchProfile: Resolved<JobSearchProfile>;
model: Resolved<string>;
llmProvider: Resolved<string>;
llmBaseUrl: Resolved<string>;
@ -183,11 +224,14 @@ export interface AppSettings {
modelProjectSelection: ModelResolved;
// Simple strings:
activeProfileId: string | null;
rxresumeBaseResumeId: string | null;
rxresumeBaseResumeIdV4: string | null;
rxresumeBaseResumeIdV5: string | null;
rxresumeEmail: string | null;
rxresumeUrl: string | null;
/** Path to local Reactive Resume JSON (see JOBOPS_LOCAL_RESUME_PATH). */
localResumeProfilePath: string | null;
ukvisajobsEmail: string | null;
adzunaAppId: string | null;
basicAuthUser: string | null;
@ -203,5 +247,7 @@ export interface AppSettings {
// Computed:
basicAuthActive: boolean;
/** True when JOBOPS_LOCAL_RESUME_PATH is set on the server (not shown in UI). */
localResumeFileConfigured: boolean;
profileProjects: ResumeProjectCatalogItem[];
}