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:
parent
0a7dbb4f16
commit
fea00ae656
12
.env.example
12
.env.example
@ -12,6 +12,12 @@ MODEL=google/gemini-3-flash-preview
|
|||||||
# Defaults to https://v4.rxresu.me
|
# Defaults to https://v4.rxresu.me
|
||||||
# RXRESUME_URL=
|
# 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
|
# RXResume credentials for PDF generation
|
||||||
# Create an account at: https://v4.rxresu.me
|
# Create an account at: https://v4.rxresu.me
|
||||||
RXRESUME_EMAIL=your_email@example.com
|
RXRESUME_EMAIL=your_email@example.com
|
||||||
@ -24,8 +30,10 @@ BASIC_AUTH_USER=
|
|||||||
BASIC_AUTH_PASSWORD=
|
BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
# Optional: client build only — skip RxResume steps in the onboarding wizard (search without PDF export).
|
# 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.
|
# Prefer setting `JOBOPS_LOCAL_RESUME_PATH` above: the API tells the UI to skip RxResume onboarding automatically.
|
||||||
# - Docker: set at IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env.
|
# 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
|
# VITE_SKIP_RXRESUME_ONBOARDING=true
|
||||||
|
|
||||||
# Public base URL used to generate tracer links when PDFs are created by
|
# Public base URL used to generate tracer links when PDFs are created by
|
||||||
|
|||||||
@ -9,8 +9,12 @@
|
|||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!!**/dist",
|
"!!**/dist",
|
||||||
|
"!!**/.venv",
|
||||||
"!!docs-site/.docusaurus",
|
"!!docs-site/.docusaurus",
|
||||||
"!!docs-site/build"
|
"!!docs-site/build",
|
||||||
|
"!!extractors/jobspy/storage",
|
||||||
|
"!!orchestrator/storage",
|
||||||
|
"!!data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
|
|||||||
2
extractors/jobspy/.gitignore
vendored
Normal file
2
extractors/jobspy/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.venv/
|
||||||
|
storage/
|
||||||
34
extractors/jobspy/README.md
Normal file
34
extractors/jobspy/README.md
Normal 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`).
|
||||||
@ -1,2 +1,3 @@
|
|||||||
|
# python-jobspy requires Python 3.10+ (wheels not published for 3.9 and below).
|
||||||
python-jobspy
|
python-jobspy
|
||||||
pandas
|
pandas
|
||||||
|
|||||||
6
orchestrator/.env.example
Normal file
6
orchestrator/.env.example
Normal 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
|
||||||
3
orchestrator/.gitignore
vendored
3
orchestrator/.gitignore
vendored
@ -7,6 +7,9 @@ dist/
|
|||||||
# Data (local database and generated files)
|
# Data (local database and generated files)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Extractor / Apify-style KV written under cwd during pipeline runs
|
||||||
|
storage/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type {
|
|||||||
JobChatThread,
|
JobChatThread,
|
||||||
JobListItem,
|
JobListItem,
|
||||||
JobOutcome,
|
JobOutcome,
|
||||||
|
JobSearchProfile,
|
||||||
JobSource,
|
JobSource,
|
||||||
JobsListResponse,
|
JobsListResponse,
|
||||||
JobsRevisionResponse,
|
JobsRevisionResponse,
|
||||||
@ -39,6 +40,7 @@ import type {
|
|||||||
ResumeProfile,
|
ResumeProfile,
|
||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
RxResumeMode,
|
RxResumeMode,
|
||||||
|
SearchProfile,
|
||||||
StageEvent,
|
StageEvent,
|
||||||
StageEventMetadata,
|
StageEventMetadata,
|
||||||
StageTransitionTarget,
|
StageTransitionTarget,
|
||||||
@ -1509,3 +1511,46 @@ export async function deleteBackup(filename: string): Promise<void> {
|
|||||||
method: "DELETE",
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
72
orchestrator/src/client/components/CoverLetterDisplay.tsx
Normal file
72
orchestrator/src/client/components/CoverLetterDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,13 @@
|
|||||||
import type { Job } from "@shared/types.js";
|
import type { Job, SuitabilityAnalysis } from "@shared/types.js";
|
||||||
import { Sparkles } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Lightbulb,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FitAssessmentProps {
|
interface FitAssessmentProps {
|
||||||
@ -8,23 +15,156 @@ interface FitAssessmentProps {
|
|||||||
className?: string;
|
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> = ({
|
export const FitAssessment: React.FC<FitAssessmentProps> = ({
|
||||||
job,
|
job,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
if (!job.suitabilityReason) return null;
|
const analysis = useMemo(
|
||||||
|
() => parseAnalysis(job.suitabilityAnalysis ?? null),
|
||||||
|
[job.suitabilityAnalysis],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!job.suitabilityReason && !analysis) return null;
|
||||||
|
|
||||||
return (
|
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="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">
|
<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" />
|
<Sparkles className="h-3 w-3" />
|
||||||
Fit Assessment
|
Fit Assessment
|
||||||
|
{analysis && <RoleMatchBadge score={analysis.roleTypeMatch} />}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
{job.suitabilityReason && (
|
||||||
{job.suitabilityReason}
|
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
||||||
</p>
|
{job.suitabilityReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -110,6 +110,8 @@ const settingsResponse = {
|
|||||||
rxresumeApiKeyHint: null,
|
rxresumeApiKeyHint: null,
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
|
localResumeProfilePath: null,
|
||||||
|
localResumeFileConfigured: false,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
refreshSettings: vi.fn(),
|
refreshSettings: vi.fn(),
|
||||||
@ -178,6 +180,25 @@ describe("OnboardingGate", () => {
|
|||||||
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
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 () => {
|
it("skips LLM key validation for providers without API keys", async () => {
|
||||||
vi.mocked(useSettings).mockReturnValue({
|
vi.mocked(useSettings).mockReturnValue({
|
||||||
...settingsResponse,
|
...settingsResponse,
|
||||||
|
|||||||
@ -94,14 +94,20 @@ function getStepPrimaryLabel(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingGate: React.FC = () => {
|
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 {
|
const {
|
||||||
settings,
|
settings,
|
||||||
isLoading: settingsLoading,
|
isLoading: settingsLoading,
|
||||||
refreshSettings,
|
refreshSettings,
|
||||||
} = useSettings();
|
} = 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 {
|
const {
|
||||||
storedRxResume,
|
storedRxResume,
|
||||||
getBaseResumeIdForMode,
|
getBaseResumeIdForMode,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
export { CoverLetterDisplay } from "./CoverLetterDisplay";
|
||||||
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
|
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
|
||||||
export { FitAssessment } from "./FitAssessment";
|
export { FitAssessment } from "./FitAssessment";
|
||||||
export { JobHeader } from "./JobHeader";
|
export { JobHeader } from "./JobHeader";
|
||||||
|
|||||||
@ -186,6 +186,7 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
canRescoreSelected,
|
canRescoreSelected,
|
||||||
|
canGenerateCoverLetter,
|
||||||
jobActionInFlight,
|
jobActionInFlight,
|
||||||
toggleSelectJob,
|
toggleSelectJob,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
@ -437,10 +438,12 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
canMoveSelected={canMoveSelected}
|
canMoveSelected={canMoveSelected}
|
||||||
canSkipSelected={canSkipSelected}
|
canSkipSelected={canSkipSelected}
|
||||||
canRescoreSelected={canRescoreSelected}
|
canRescoreSelected={canRescoreSelected}
|
||||||
|
canGenerateCoverLetter={canGenerateCoverLetter}
|
||||||
jobActionInFlight={jobActionInFlight !== null}
|
jobActionInFlight={jobActionInFlight !== null}
|
||||||
onMoveToReady={() => void runJobAction("move_to_ready")}
|
onMoveToReady={() => void runJobAction("move_to_ready")}
|
||||||
onSkipSelected={() => void runJobAction("skip")}
|
onSkipSelected={() => void runJobAction("skip")}
|
||||||
onRescoreSelected={() => void runJobAction("rescore")}
|
onRescoreSelected={() => void runJobAction("rescore")}
|
||||||
|
onGenerateCoverLetter={() => void runJobAction("generate_cover_letter")}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { ChatSettingsSection } from "@client/pages/settings/components/ChatSetti
|
|||||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
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 { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||||
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||||
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||||
@ -60,6 +61,7 @@ import { Accordion } from "@/components/ui/accordion";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||||
|
jobSearchProfile: null,
|
||||||
model: "",
|
model: "",
|
||||||
modelScorer: "",
|
modelScorer: "",
|
||||||
modelTailoring: "",
|
modelTailoring: "",
|
||||||
@ -82,6 +84,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
chatStyleManualLanguage: null,
|
chatStyleManualLanguage: null,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
rxresumeUrl: "",
|
rxresumeUrl: "",
|
||||||
|
localResumeProfilePath: "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
basicAuthUser: "",
|
basicAuthUser: "",
|
||||||
@ -137,6 +140,7 @@ const normalizeLlmProviderValue = (
|
|||||||
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
||||||
|
|
||||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||||
|
jobSearchProfile: null,
|
||||||
model: null,
|
model: null,
|
||||||
modelScorer: null,
|
modelScorer: null,
|
||||||
modelTailoring: null,
|
modelTailoring: null,
|
||||||
@ -159,6 +163,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
chatStyleManualLanguage: null,
|
chatStyleManualLanguage: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
rxresumeUrl: null,
|
rxresumeUrl: null,
|
||||||
|
localResumeProfilePath: null,
|
||||||
rxresumePassword: null,
|
rxresumePassword: null,
|
||||||
rxresumeApiKey: null,
|
rxresumeApiKey: null,
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
@ -181,6 +186,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||||
|
jobSearchProfile: data.jobSearchProfile?.override ?? null,
|
||||||
model: data.model.override ?? "",
|
model: data.model.override ?? "",
|
||||||
modelScorer: data.modelScorer.override ?? "",
|
modelScorer: data.modelScorer.override ?? "",
|
||||||
modelTailoring: data.modelTailoring.override ?? "",
|
modelTailoring: data.modelTailoring.override ?? "",
|
||||||
@ -204,6 +210,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
||||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||||
rxresumeUrl: data.rxresumeUrl ?? "",
|
rxresumeUrl: data.rxresumeUrl ?? "",
|
||||||
|
localResumeProfilePath: data.localResumeProfilePath ?? "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
basicAuthUser: data.basicAuthUser ?? "",
|
basicAuthUser: data.basicAuthUser ?? "",
|
||||||
@ -370,6 +377,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
default: settings?.backupMaxCount?.default ?? 5,
|
default: settings?.backupMaxCount?.default ?? 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
jobSearchProfile: {
|
||||||
|
effective: settings?.jobSearchProfile?.value ?? null,
|
||||||
|
default: settings?.jobSearchProfile?.default ?? null,
|
||||||
|
},
|
||||||
scoring: {
|
scoring: {
|
||||||
penalizeMissingSalary: {
|
penalizeMissingSalary: {
|
||||||
effective: settings?.penalizeMissingSalary?.value ?? false,
|
effective: settings?.penalizeMissingSalary?.value ?? false,
|
||||||
@ -572,6 +583,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
profileProjects,
|
profileProjects,
|
||||||
backup,
|
backup,
|
||||||
scoring,
|
scoring,
|
||||||
|
jobSearchProfile,
|
||||||
} = derived;
|
} = derived;
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
const handleCreateBackup = async () => {
|
||||||
@ -769,6 +781,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
|
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dirtyFields.localResumeProfilePath) {
|
||||||
|
envPayload.localResumeProfilePath = normalizeString(
|
||||||
|
data.localResumeProfilePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
||||||
}
|
}
|
||||||
@ -833,7 +851,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
if (value !== undefined) envPayload.webhookSecret = value;
|
if (value !== undefined) envPayload.webhookSecret = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jobSearchProfilePayload = dirtyFields.jobSearchProfile
|
||||||
|
? data.jobSearchProfile
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const payload: Partial<UpdateSettingsInput> = {
|
const payload: Partial<UpdateSettingsInput> = {
|
||||||
|
...(jobSearchProfilePayload !== undefined
|
||||||
|
? { jobSearchProfile: jobSearchProfilePayload }
|
||||||
|
: {}),
|
||||||
model: dirtyFields.llmProvider
|
model: dirtyFields.llmProvider
|
||||||
? dirtyFields.model
|
? dirtyFields.model
|
||||||
? normalizeString(data.model)
|
? normalizeString(data.model)
|
||||||
@ -986,6 +1011,21 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await updateSettingsMutation.mutateAsync(payload);
|
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);
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated));
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
@ -1167,6 +1207,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
<JobSearchProfileSection
|
||||||
|
values={jobSearchProfile}
|
||||||
|
activeProfileId={settings?.activeProfileId ?? null}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
<ScoringSettingsSection
|
<ScoringSettingsSection
|
||||||
values={scoring}
|
values={scoring}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@ -7,10 +7,12 @@ interface FloatingJobActionsBarProps {
|
|||||||
canMoveSelected: boolean;
|
canMoveSelected: boolean;
|
||||||
canSkipSelected: boolean;
|
canSkipSelected: boolean;
|
||||||
canRescoreSelected: boolean;
|
canRescoreSelected: boolean;
|
||||||
|
canGenerateCoverLetter: boolean;
|
||||||
jobActionInFlight: boolean;
|
jobActionInFlight: boolean;
|
||||||
onMoveToReady: () => void;
|
onMoveToReady: () => void;
|
||||||
onSkipSelected: () => void;
|
onSkipSelected: () => void;
|
||||||
onRescoreSelected: () => void;
|
onRescoreSelected: () => void;
|
||||||
|
onGenerateCoverLetter: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,10 +21,12 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
|||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
canRescoreSelected,
|
canRescoreSelected,
|
||||||
|
canGenerateCoverLetter,
|
||||||
jobActionInFlight,
|
jobActionInFlight,
|
||||||
onMoveToReady,
|
onMoveToReady,
|
||||||
onSkipSelected,
|
onSkipSelected,
|
||||||
onRescoreSelected,
|
onRescoreSelected,
|
||||||
|
onGenerateCoverLetter,
|
||||||
onClear,
|
onClear,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@ -76,6 +80,18 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
|||||||
Recalculate match
|
Recalculate match
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canGenerateCoverLetter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={jobActionInFlight}
|
||||||
|
onClick={onGenerateCoverLetter}
|
||||||
|
>
|
||||||
|
Cover letter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -52,6 +52,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@client/components", () => ({
|
vi.mock("@client/components", () => ({
|
||||||
|
CoverLetterDisplay: () => <div data-testid="cover-letter-display" />,
|
||||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import {
|
import {
|
||||||
|
CoverLetterDisplay,
|
||||||
DiscoveredPanel,
|
DiscoveredPanel,
|
||||||
FitAssessment,
|
FitAssessment,
|
||||||
JobHeader,
|
JobHeader,
|
||||||
@ -601,6 +602,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
|
|
||||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||||
<FitAssessment job={selectedJob} />
|
<FitAssessment job={selectedJob} />
|
||||||
|
<CoverLetterDisplay job={selectedJob} />
|
||||||
<TailoredSummary job={selectedJob} />
|
<TailoredSummary job={selectedJob} />
|
||||||
|
|
||||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||||
|
|||||||
@ -16,6 +16,10 @@ export function canRescore(jobs: JobListItem[]): boolean {
|
|||||||
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
|
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> {
|
export function getFailedJobIds(response: JobActionResponse): Set<string> {
|
||||||
const failedIds = response.results
|
const failedIds = response.results
|
||||||
.filter((result) => !result.ok)
|
.filter((result) => !result.ok)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { trackProductEvent } from "@/lib/analytics";
|
|||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
import { JobActionProgressToast } from "./JobActionProgressToast";
|
import { JobActionProgressToast } from "./JobActionProgressToast";
|
||||||
import {
|
import {
|
||||||
|
canGenerateCoverLetter,
|
||||||
canMoveToReady,
|
canMoveToReady,
|
||||||
canRescore,
|
canRescore,
|
||||||
canSkip,
|
canSkip,
|
||||||
@ -23,12 +24,14 @@ const jobActionLabel: Record<JobAction, string> = {
|
|||||||
move_to_ready: "Moving jobs to Ready...",
|
move_to_ready: "Moving jobs to Ready...",
|
||||||
skip: "Skipping selected jobs...",
|
skip: "Skipping selected jobs...",
|
||||||
rescore: "Calculating match scores...",
|
rescore: "Calculating match scores...",
|
||||||
|
generate_cover_letter: "Generating cover letters...",
|
||||||
};
|
};
|
||||||
|
|
||||||
const jobActionSuccessLabel: Record<JobAction, string> = {
|
const jobActionSuccessLabel: Record<JobAction, string> = {
|
||||||
move_to_ready: "jobs moved to Ready",
|
move_to_ready: "jobs moved to Ready",
|
||||||
skip: "jobs skipped",
|
skip: "jobs skipped",
|
||||||
rescore: "matches recalculated",
|
rescore: "matches recalculated",
|
||||||
|
generate_cover_letter: "cover letters generated",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseJobSelectionActionsArgs {
|
interface UseJobSelectionActionsArgs {
|
||||||
@ -64,6 +67,10 @@ export function useJobSelectionActions({
|
|||||||
() => canRescore(selectedJobs),
|
() => canRescore(selectedJobs),
|
||||||
[selectedJobs],
|
[selectedJobs],
|
||||||
);
|
);
|
||||||
|
const canGenerateCoverLetterSelected = useMemo(
|
||||||
|
() => canGenerateCoverLetter(selectedJobs),
|
||||||
|
[selectedJobs],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousActiveTabRef.current === activeTab) return;
|
if (previousActiveTabRef.current === activeTab) return;
|
||||||
@ -283,6 +290,7 @@ export function useJobSelectionActions({
|
|||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
canRescoreSelected,
|
canRescoreSelected,
|
||||||
|
canGenerateCoverLetter: canGenerateCoverLetterSelected,
|
||||||
jobActionInFlight,
|
jobActionInFlight,
|
||||||
toggleSelectJob,
|
toggleSelectJob,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -13,6 +13,8 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
type ReactiveResumeSectionProps = {
|
type ReactiveResumeSectionProps = {
|
||||||
rxResumeBaseResumeIdDraft: string | null;
|
rxResumeBaseResumeIdDraft: string | null;
|
||||||
@ -73,6 +75,8 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
|
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
|
||||||
const rxresumePasswordValue =
|
const rxresumePasswordValue =
|
||||||
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
||||||
|
const localResumeProfilePathValue =
|
||||||
|
useWatch({ control, name: "localResumeProfilePath" }) ?? "";
|
||||||
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
||||||
const setDirtyTouchedValue = <TField extends Path<UpdateSettingsInput>>(
|
const setDirtyTouchedValue = <TField extends Path<UpdateSettingsInput>>(
|
||||||
field: TField,
|
field: TField,
|
||||||
@ -97,7 +101,31 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
<span className="text-base font-semibold">Reactive Resume</span>
|
<span className="text-base font-semibold">Reactive Resume</span>
|
||||||
</AccordionTrigger>
|
</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
|
<ReactiveResumeConfigPanel
|
||||||
mode={selectedMode}
|
mode={selectedMode}
|
||||||
onModeChange={(mode) => {
|
onModeChange={(mode) => {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { pipelineRouter } from "./routes/pipeline";
|
|||||||
import { postApplicationProvidersRouter } from "./routes/post-application-providers";
|
import { postApplicationProvidersRouter } from "./routes/post-application-providers";
|
||||||
import { postApplicationReviewRouter } from "./routes/post-application-review";
|
import { postApplicationReviewRouter } from "./routes/post-application-review";
|
||||||
import { profileRouter } from "./routes/profile";
|
import { profileRouter } from "./routes/profile";
|
||||||
|
import { profilesRouter } from "./routes/profiles";
|
||||||
import { settingsRouter } from "./routes/settings";
|
import { settingsRouter } from "./routes/settings";
|
||||||
import { tracerLinksRouter } from "./routes/tracer-links";
|
import { tracerLinksRouter } from "./routes/tracer-links";
|
||||||
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
||||||
@ -31,6 +32,7 @@ apiRouter.use("/post-application", postApplicationReviewRouter);
|
|||||||
apiRouter.use("/manual-jobs", manualJobsRouter);
|
apiRouter.use("/manual-jobs", manualJobsRouter);
|
||||||
apiRouter.use("/webhook", webhookRouter);
|
apiRouter.use("/webhook", webhookRouter);
|
||||||
apiRouter.use("/profile", profileRouter);
|
apiRouter.use("/profile", profileRouter);
|
||||||
|
apiRouter.use("/profiles", profilesRouter);
|
||||||
apiRouter.use("/database", databaseRouter);
|
apiRouter.use("/database", databaseRouter);
|
||||||
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||||
apiRouter.use("/onboarding", onboardingRouter);
|
apiRouter.use("/onboarding", onboardingRouter);
|
||||||
|
|||||||
@ -558,6 +558,7 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||||
score: 81,
|
score: 81,
|
||||||
reason: "Updated fit from action rescore",
|
reason: "Updated fit from action rescore",
|
||||||
|
analysis: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const discovered = await createJob({
|
const discovered = await createJob({
|
||||||
@ -754,6 +755,7 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||||
score: 77,
|
score: 77,
|
||||||
reason: "Updated fit",
|
reason: "Updated fit",
|
||||||
|
analysis: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = await createJob({
|
const job = await createJob({
|
||||||
|
|||||||
@ -210,6 +210,10 @@ const jobActionRequestSchema = z.discriminatedUnion("action", [
|
|||||||
action: z.literal("rescore"),
|
action: z.literal("rescore"),
|
||||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal("generate_cover_letter"),
|
||||||
|
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
action: z.literal("move_to_ready"),
|
action: z.literal("move_to_ready"),
|
||||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
@ -412,48 +416,104 @@ async function executeJobActionForJob(
|
|||||||
return { jobId, ok: true, job: updated };
|
return { jobId, ok: true, job: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.status === "processing") {
|
if (action === "rescore") {
|
||||||
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
if (job.status === "processing") {
|
||||||
jobId,
|
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
||||||
status: job.status,
|
jobId,
|
||||||
disallowedStatus: "processing",
|
status: job.status,
|
||||||
|
disallowedStatus: "processing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDemoMode()) {
|
||||||
|
const simulated = await simulateRescoreJob(job.id);
|
||||||
|
return { jobId, ok: true, job: simulated };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = options?.getProfileForRescore
|
||||||
|
? await options.getProfileForRescore()
|
||||||
|
: 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 { 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({
|
||||||
|
status: 404,
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Job not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { jobId, ok: true, job: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDemoMode()) {
|
if (action === "generate_cover_letter") {
|
||||||
const simulated = await simulateRescoreJob(job.id);
|
if (job.status === "processing") {
|
||||||
return { jobId, ok: true, job: simulated };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = options?.getProfileForRescore
|
throw badRequest(`Unknown action: ${action}`);
|
||||||
? await options.getProfileForRescore()
|
|
||||||
: 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 { score, reason } = await scoreJobSuitability(job, profile);
|
|
||||||
|
|
||||||
const updated = await jobsRepo.updateJob(job.id, {
|
|
||||||
suitabilityScore: score,
|
|
||||||
suitabilityReason: reason,
|
|
||||||
});
|
|
||||||
if (!updated) {
|
|
||||||
throw new AppError({
|
|
||||||
status: 404,
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Job not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { jobId, ok: true, job: updated };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = mapErrorForResult(error);
|
const mapped = mapErrorForResult(error);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -70,6 +70,7 @@ describe.sequential("Manual jobs API routes", () => {
|
|||||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||||
score: 88,
|
score: 88,
|
||||||
reason: "Strong fit",
|
reason: "Strong fit",
|
||||||
|
analysis: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
|
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
|
||||||
|
|||||||
@ -269,13 +269,14 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
|
|||||||
throw new Error("Invalid resume profile format");
|
throw new Error("Invalid resume profile format");
|
||||||
}
|
}
|
||||||
const profile = rawProfile as Record<string, unknown>;
|
const profile = rawProfile as Record<string, unknown>;
|
||||||
const { score, reason } = await scoreJobSuitability(
|
const { score, reason, analysis } = await scoreJobSuitability(
|
||||||
processedJob,
|
processedJob,
|
||||||
profile,
|
profile,
|
||||||
);
|
);
|
||||||
await jobsRepo.updateJob(processedJob.id, {
|
await jobsRepo.updateJob(processedJob.id, {
|
||||||
suitabilityScore: score,
|
suitabilityScore: score,
|
||||||
suitabilityReason: reason,
|
suitabilityReason: reason,
|
||||||
|
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("Manual job scoring failed", {
|
logger.warn("Manual job scoring failed", {
|
||||||
|
|||||||
@ -509,7 +509,9 @@ describe.sequential("Onboarding API routes", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.data.valid).toBe(false);
|
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
|
// Note: Further validation tests require mocking getSetting and getResume
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import { logger } from "@infra/logger";
|
|||||||
import { isDemoMode } from "@server/config/demo";
|
import { isDemoMode } from "@server/config/demo";
|
||||||
import { getSetting } from "@server/repositories/settings";
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import { LlmService } from "@server/services/llm/service";
|
import { LlmService } from "@server/services/llm/service";
|
||||||
|
import { getProfile } from "@server/services/profile";
|
||||||
import {
|
import {
|
||||||
getResume,
|
|
||||||
RxResumeAuthConfigError,
|
RxResumeAuthConfigError,
|
||||||
validateResumeSchema,
|
validateResumeSchema,
|
||||||
validateCredentials as validateRxResumeCredentials,
|
validateCredentials as validateRxResumeCredentials,
|
||||||
} from "@server/services/rxresume";
|
} from "@server/services/rxresume";
|
||||||
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
|
||||||
import { type Request, type Response, Router } from "express";
|
import { type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
export const onboardingRouter = Router();
|
export const onboardingRouter = Router();
|
||||||
@ -80,53 +79,25 @@ 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> {
|
async function validateResumeConfig(): Promise<ValidationResponse> {
|
||||||
try {
|
try {
|
||||||
// Check if rxresumeBaseResumeId is configured
|
const profile = await getProfile();
|
||||||
const { resumeId: rxresumeBaseResumeId } =
|
const validated = await validateResumeSchema(
|
||||||
await getConfiguredRxResumeBaseResumeId();
|
profile as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
if (!rxresumeBaseResumeId) {
|
if (validated.ok) {
|
||||||
|
return { valid: true, message: null };
|
||||||
|
}
|
||||||
|
return { valid: false, message: validated.message };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RxResumeAuthConfigError) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message:
|
message: error.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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true, message: null };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof RxResumeAuthConfigError) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to fetch resume from RxResume.";
|
|
||||||
return { valid: false, message };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Resume validation failed.";
|
error instanceof Error ? error.message : "Resume validation failed.";
|
||||||
return { valid: false, message };
|
return { valid: false, message };
|
||||||
|
|||||||
124
orchestrator/src/server/api/routes/profiles.ts
Normal file
124
orchestrator/src/server/api/routes/profiles.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -336,6 +336,21 @@ const migrations = [
|
|||||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
`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
|
// Add application tracking columns
|
||||||
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
|
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
|
||||||
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
|
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
|
||||||
@ -436,6 +451,8 @@ const migrations = [
|
|||||||
closed_at INTEGER,
|
closed_at INTEGER,
|
||||||
suitability_score REAL,
|
suitability_score REAL,
|
||||||
suitability_reason TEXT,
|
suitability_reason TEXT,
|
||||||
|
suitability_analysis TEXT,
|
||||||
|
cover_letter TEXT,
|
||||||
tailored_summary TEXT,
|
tailored_summary TEXT,
|
||||||
tailored_headline TEXT,
|
tailored_headline TEXT,
|
||||||
tailored_skills TEXT,
|
tailored_skills TEXT,
|
||||||
@ -457,7 +474,7 @@ const migrations = [
|
|||||||
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
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,
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
||||||
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||||
applied_at, created_at, updated_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,
|
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,
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
||||||
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||||
applied_at, created_at, updated_at
|
applied_at, created_at, updated_at
|
||||||
FROM jobs`,
|
FROM jobs`,
|
||||||
|
|||||||
@ -93,6 +93,7 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
closedAt: integer("closed_at", { mode: "number" }),
|
closedAt: integer("closed_at", { mode: "number" }),
|
||||||
suitabilityScore: real("suitability_score"),
|
suitabilityScore: real("suitability_score"),
|
||||||
suitabilityReason: text("suitability_reason"),
|
suitabilityReason: text("suitability_reason"),
|
||||||
|
suitabilityAnalysis: text("suitability_analysis"),
|
||||||
tailoredSummary: text("tailored_summary"),
|
tailoredSummary: text("tailored_summary"),
|
||||||
tailoredHeadline: text("tailored_headline"),
|
tailoredHeadline: text("tailored_headline"),
|
||||||
tailoredSkills: text("tailored_skills"),
|
tailoredSkills: text("tailored_skills"),
|
||||||
@ -101,6 +102,7 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
|
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
coverLetter: text("cover_letter"),
|
||||||
sponsorMatchScore: real("sponsor_match_score"),
|
sponsorMatchScore: real("sponsor_match_score"),
|
||||||
sponsorMatchNames: text("sponsor_match_names"),
|
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", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
@ -454,6 +464,8 @@ export type JobChatMessageRow = typeof jobChatMessages.$inferSelect;
|
|||||||
export type NewJobChatMessageRow = typeof jobChatMessages.$inferInsert;
|
export type NewJobChatMessageRow = typeof jobChatMessages.$inferInsert;
|
||||||
export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
|
export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
|
||||||
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
|
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 SettingsRow = typeof settings.$inferSelect;
|
||||||
export type NewSettingsRow = typeof settings.$inferInsert;
|
export type NewSettingsRow = typeof settings.$inferInsert;
|
||||||
export type PostApplicationIntegrationRow =
|
export type PostApplicationIntegrationRow =
|
||||||
|
|||||||
@ -87,7 +87,11 @@ describe("Sponsor Match Calculation", () => {
|
|||||||
createJobs = jobsRepo.createJobs as ReturnType<typeof vi.fn>;
|
createJobs = jobsRepo.createJobs as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
// Default mock implementations
|
// 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 });
|
createJobs.mockResolvedValue({ created: 0, skipped: 0 });
|
||||||
updateJob.mockResolvedValue(undefined);
|
updateJob.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
it("throws when all enabled sources fail", async () => {
|
||||||
const settingsRepo = await import("@server/repositories/settings");
|
const settingsRepo = await import("@server/repositories/settings");
|
||||||
const registryModule = await import("@server/extractors/registry");
|
const registryModule = await import("@server/extractors/registry");
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "@shared/location-support.js";
|
} from "@shared/location-support.js";
|
||||||
import { normalizeStringArray } from "@shared/normalize-string-array.js";
|
import { normalizeStringArray } from "@shared/normalize-string-array.js";
|
||||||
import {
|
import {
|
||||||
|
inferCountryKeyFromSearchGeography,
|
||||||
matchesRequestedCity,
|
matchesRequestedCity,
|
||||||
resolveSearchCities,
|
resolveSearchCities,
|
||||||
shouldApplyStrictCityFilter,
|
shouldApplyStrictCityFilter,
|
||||||
@ -106,12 +107,67 @@ export async function discoverJobsStep(args: {
|
|||||||
.filter(Boolean);
|
.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(
|
const selectedCountry = normalizeCountryKey(
|
||||||
settings.jobspyCountryIndeed ??
|
geographyCountryKey ??
|
||||||
|
settings.jobspyCountryIndeed ??
|
||||||
settings.searchCities ??
|
settings.searchCities ??
|
||||||
settings.jobspyLocation ??
|
settings.jobspyLocation ??
|
||||||
"united kingdom",
|
"united kingdom",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveJobspyCountryIndeed =
|
||||||
|
geographyCountryKey ?? settings.jobspyCountryIndeed;
|
||||||
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
||||||
isSourceAllowedForCountry(source, selectedCountry),
|
isSourceAllowedForCountry(source, selectedCountry),
|
||||||
);
|
);
|
||||||
@ -188,6 +244,10 @@ export async function discoverJobsStep(args: {
|
|||||||
),
|
),
|
||||||
) as Record<string, string | undefined>;
|
) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
if (effectiveJobspyCountryIndeed !== undefined) {
|
||||||
|
filteredSettings.jobspyCountryIndeed = effectiveJobspyCountryIndeed;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await manifest.run({
|
const result = await manifest.run({
|
||||||
source: grouped.sources[0],
|
source: grouped.sources[0],
|
||||||
selectedSources: grouped.sources,
|
selectedSources: grouped.sources,
|
||||||
|
|||||||
@ -59,6 +59,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
|||||||
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||||
score: 40,
|
score: 40,
|
||||||
reason: "Low fit",
|
reason: "Low fit",
|
||||||
|
analysis: null,
|
||||||
});
|
});
|
||||||
vi.mocked(visaSponsors.searchSponsors).mockResolvedValue([]);
|
vi.mocked(visaSponsors.searchSponsors).mockResolvedValue([]);
|
||||||
vi.mocked(visaSponsors.calculateSponsorMatchSummary).mockReturnValue({
|
vi.mocked(visaSponsors.calculateSponsorMatchSummary).mockReturnValue({
|
||||||
@ -103,6 +104,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
|||||||
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||||
score: 50,
|
score: 50,
|
||||||
reason: "At threshold",
|
reason: "At threshold",
|
||||||
|
analysis: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await scoreJobsStep({ profile: {} });
|
await scoreJobsStep({ profile: {} });
|
||||||
@ -205,8 +207,16 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
vi.mocked(scorer.scoreJobSuitability)
|
vi.mocked(scorer.scoreJobSuitability)
|
||||||
.mockResolvedValueOnce({ score: 61, reason: "First score" })
|
.mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({ score: 72, reason: "Second score" });
|
score: 61,
|
||||||
|
reason: "First score",
|
||||||
|
analysis: null,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
score: 72,
|
||||||
|
reason: "Second score",
|
||||||
|
analysis: null,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await scoreJobsStep({ profile: {} });
|
const result = await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,10 @@ export async function scoreJobsStep(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { score, reason } = await scoreJobSuitability(job, args.profile);
|
const { score, reason, analysis } = await scoreJobSuitability(
|
||||||
|
job,
|
||||||
|
args.profile,
|
||||||
|
);
|
||||||
if (args.shouldCancel?.()) return;
|
if (args.shouldCancel?.()) return;
|
||||||
|
|
||||||
let sponsorMatchScore = 0;
|
let sponsorMatchScore = 0;
|
||||||
@ -81,7 +84,6 @@ export async function scoreJobsStep(args: {
|
|||||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if job should be auto-skipped based on score threshold
|
|
||||||
const shouldAutoSkip =
|
const shouldAutoSkip =
|
||||||
job.status !== "applied" &&
|
job.status !== "applied" &&
|
||||||
autoSkipThreshold !== null &&
|
autoSkipThreshold !== null &&
|
||||||
@ -91,6 +93,7 @@ export async function scoreJobsStep(args: {
|
|||||||
await jobsRepo.updateJob(job.id, {
|
await jobsRepo.updateJob(job.id, {
|
||||||
suitabilityScore: score,
|
suitabilityScore: score,
|
||||||
suitabilityReason: reason,
|
suitabilityReason: reason,
|
||||||
|
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||||
sponsorMatchScore,
|
sponsorMatchScore,
|
||||||
sponsorMatchNames,
|
sponsorMatchNames,
|
||||||
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||||
|
|||||||
@ -17,7 +17,9 @@ import { db, schema } from "../db/index";
|
|||||||
|
|
||||||
const { jobs } = schema;
|
const { jobs } = schema;
|
||||||
|
|
||||||
function normalizeCreateJobInputForDedup(input: CreateJobInput): CreateJobInput {
|
function normalizeCreateJobInputForDedup(
|
||||||
|
input: CreateJobInput,
|
||||||
|
): CreateJobInput {
|
||||||
const jobUrl = canonicalizeJobUrl(input.jobUrl);
|
const jobUrl = canonicalizeJobUrl(input.jobUrl);
|
||||||
if (jobUrl === input.jobUrl) return input;
|
if (jobUrl === input.jobUrl) return input;
|
||||||
return { ...input, jobUrl };
|
return { ...input, jobUrl };
|
||||||
@ -45,8 +47,7 @@ async function loadJobDedupIndexes(): Promise<{
|
|||||||
const existingSourceJobKeySet = new Set(
|
const existingSourceJobKeySet = new Set(
|
||||||
rows
|
rows
|
||||||
.filter(
|
.filter(
|
||||||
(r) =>
|
(r) => r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
|
||||||
r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
|
|
||||||
)
|
)
|
||||||
.map((r) => sourceJobKey(r.source, String(r.sourceJobId))),
|
.map((r) => sourceJobKey(r.source, String(r.sourceJobId))),
|
||||||
);
|
);
|
||||||
@ -54,7 +55,10 @@ async function loadJobDedupIndexes(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findJobByCanonicalUrl(canonical: string): Promise<Job | null> {
|
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);
|
if (exact) return mapRowToJob(exact);
|
||||||
|
|
||||||
const allRows = await db.select().from(jobs);
|
const allRows = await db.select().from(jobs);
|
||||||
@ -547,6 +551,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
|||||||
closedAt: row.closedAt ?? null,
|
closedAt: row.closedAt ?? null,
|
||||||
suitabilityScore: row.suitabilityScore,
|
suitabilityScore: row.suitabilityScore,
|
||||||
suitabilityReason: row.suitabilityReason,
|
suitabilityReason: row.suitabilityReason,
|
||||||
|
suitabilityAnalysis: row.suitabilityAnalysis ?? null,
|
||||||
|
coverLetter: row.coverLetter ?? null,
|
||||||
tailoredSummary: row.tailoredSummary,
|
tailoredSummary: row.tailoredSummary,
|
||||||
tailoredHeadline: row.tailoredHeadline ?? null,
|
tailoredHeadline: row.tailoredHeadline ?? null,
|
||||||
tailoredSkills: row.tailoredSkills ?? null,
|
tailoredSkills: row.tailoredSkills ?? null,
|
||||||
|
|||||||
95
orchestrator/src/server/repositories/profiles.ts
Normal file
95
orchestrator/src/server/repositories/profiles.ts
Normal 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;
|
||||||
|
}
|
||||||
131
orchestrator/src/server/services/cover-letter.ts
Normal file
131
orchestrator/src/server/services/cover-letter.ts
Normal 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 };
|
||||||
|
}
|
||||||
173
orchestrator/src/server/services/profile-generator.ts
Normal file
173
orchestrator/src/server/services/profile-generator.ts
Normal 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 : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { clearProfileCache, getProfile } from "./profile";
|
import { clearProfileCache, getProfile } from "./profile";
|
||||||
|
|
||||||
@ -19,23 +22,42 @@ vi.mock("./rxresume", () => ({
|
|||||||
import { getSetting } from "../repositories/settings";
|
import { getSetting } from "../repositories/settings";
|
||||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
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", () => {
|
describe("getProfile", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
clearProfileCache();
|
clearProfileCache();
|
||||||
|
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error if rxresumeBaseResumeId is not configured", async () => {
|
it("should throw an error if no local file and rxresumeBaseResumeId is not configured", async () => {
|
||||||
vi.mocked(getSetting).mockResolvedValue(null);
|
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
||||||
|
if (key === "localResumeProfilePath") return null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
await expect(getProfile()).rejects.toThrow(
|
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 () => {
|
it("should fetch profile from Reactive Resume when configured", async () => {
|
||||||
const mockResumeData = { basics: { name: "Test User" } };
|
const mockResumeData = { basics: { name: "Test User" } };
|
||||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
mockRxResumeOnlyFlow("test-resume-id");
|
||||||
vi.mocked(getResume).mockResolvedValue({
|
vi.mocked(getResume).mockResolvedValue({
|
||||||
id: "test-resume-id",
|
id: "test-resume-id",
|
||||||
data: mockResumeData,
|
data: mockResumeData,
|
||||||
@ -43,15 +65,13 @@ describe("getProfile", () => {
|
|||||||
|
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
|
|
||||||
expect(getSetting).toHaveBeenCalledWith("rxresumeMode");
|
|
||||||
expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId");
|
|
||||||
expect(getResume).toHaveBeenCalledWith("test-resume-id");
|
expect(getResume).toHaveBeenCalledWith("test-resume-id");
|
||||||
expect(profile).toEqual(mockResumeData);
|
expect(profile).toEqual(mockResumeData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cache the profile and not refetch on subsequent calls", async () => {
|
it("should cache the profile and not refetch on subsequent calls", async () => {
|
||||||
const mockResumeData = { basics: { name: "Test User" } };
|
const mockResumeData = { basics: { name: "Test User" } };
|
||||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
mockRxResumeOnlyFlow("test-resume-id");
|
||||||
vi.mocked(getResume).mockResolvedValue({
|
vi.mocked(getResume).mockResolvedValue({
|
||||||
id: "test-resume-id",
|
id: "test-resume-id",
|
||||||
data: mockResumeData,
|
data: mockResumeData,
|
||||||
@ -60,15 +80,12 @@ describe("getProfile", () => {
|
|||||||
await getProfile();
|
await 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);
|
expect(getResume).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should refetch when forceRefresh is true", async () => {
|
it("should refetch when forceRefresh is true", async () => {
|
||||||
const mockResumeData = { basics: { name: "Test User" } };
|
const mockResumeData = { basics: { name: "Test User" } };
|
||||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
mockRxResumeOnlyFlow("test-resume-id");
|
||||||
vi.mocked(getResume).mockResolvedValue({
|
vi.mocked(getResume).mockResolvedValue({
|
||||||
id: "test-resume-id",
|
id: "test-resume-id",
|
||||||
data: mockResumeData,
|
data: mockResumeData,
|
||||||
@ -86,7 +103,7 @@ describe("getProfile", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw user-friendly error on credential issues", async () => {
|
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(
|
vi.mocked(getResume).mockRejectedValue(
|
||||||
new (RxResumeAuthConfigError as unknown as new () => Error)(),
|
new (RxResumeAuthConfigError as unknown as new () => Error)(),
|
||||||
);
|
);
|
||||||
@ -97,7 +114,7 @@ describe("getProfile", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error if resume data is empty", async () => {
|
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({
|
vi.mocked(getResume).mockResolvedValue({
|
||||||
id: "test-resume-id",
|
id: "test-resume-id",
|
||||||
data: null,
|
data: null,
|
||||||
@ -107,4 +124,41 @@ describe("getProfile", () => {
|
|||||||
"Resume data is empty or invalid",
|
"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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import type { ResumeProfile } from "@shared/types";
|
import type { ResumeProfile } from "@shared/types";
|
||||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
||||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||||
@ -6,22 +9,97 @@ import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
|||||||
let cachedProfile: ResumeProfile | null = null;
|
let cachedProfile: ResumeProfile | null = null;
|
||||||
let cachedResumeId: string | 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.
|
* Results are cached until clearProfileCache() is called.
|
||||||
*
|
*
|
||||||
* @param forceRefresh Force reload from API.
|
* @param forceRefresh Force reload from disk or API.
|
||||||
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
|
|
||||||
*/
|
*/
|
||||||
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
||||||
|
const localPath = await resolveLocalResumeFilePath();
|
||||||
|
if (localPath) {
|
||||||
|
return loadProfileFromLocalFile(localPath, forceRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
const { resumeId: rxresumeBaseResumeId } =
|
const { resumeId: rxresumeBaseResumeId } =
|
||||||
await getConfiguredRxResumeBaseResumeId();
|
await getConfiguredRxResumeBaseResumeId();
|
||||||
|
|
||||||
if (!rxresumeBaseResumeId) {
|
if (!rxresumeBaseResumeId) {
|
||||||
throw new Error(
|
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 {
|
export function clearProfileCache(): void {
|
||||||
cachedProfile = null;
|
cachedProfile = null;
|
||||||
cachedResumeId = null;
|
cachedResumeId = null;
|
||||||
|
cachedLocalSourceKey = null;
|
||||||
|
cachedLocalProfile = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger } from "@infra/logger";
|
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 { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
||||||
@ -11,15 +11,17 @@ import { resolveLlmModel } from "./modelSelection";
|
|||||||
import { getEffectiveSettings } from "./settings";
|
import { getEffectiveSettings } from "./settings";
|
||||||
|
|
||||||
interface SuitabilityResult {
|
interface SuitabilityResult {
|
||||||
score: number; // 0-100
|
score: number;
|
||||||
reason: string; // Explanation
|
reason: string;
|
||||||
|
analysis: SuitabilityAnalysis | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScoringPreferences = {
|
type ScoringPreferences = {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
|
jobSearchProfile: JobSearchProfile | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** JSON schema for suitability scoring response */
|
/** JSON schema for suitability scoring response (enhanced with analysis) */
|
||||||
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
||||||
name: "job_suitability_score",
|
name: "job_suitability_score",
|
||||||
schema: {
|
schema: {
|
||||||
@ -33,12 +35,59 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "Brief 1-2 sentence explanation of the score",
|
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)",
|
||||||
|
},
|
||||||
|
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"],
|
required: [
|
||||||
|
"score",
|
||||||
|
"reason",
|
||||||
|
"roleTypeMatch",
|
||||||
|
"strengths",
|
||||||
|
"gaps",
|
||||||
|
"suggestions",
|
||||||
|
"dealBreakerHits",
|
||||||
|
],
|
||||||
additionalProperties: false,
|
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.
|
* Check if a job's salary field is missing/empty.
|
||||||
* Returns true for null, empty string, or whitespace-only strings.
|
* Returns true for null, empty string, or whitespace-only strings.
|
||||||
@ -80,6 +129,23 @@ function applySalaryPenalty(
|
|||||||
return { score: adjustedScore, reason: adjustedReason, penaltyApplied: true };
|
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.
|
* Score a job's suitability based on profile and job description.
|
||||||
* Includes retry logic for when AI returns garbage responses.
|
* Includes retry logic for when AI returns garbage responses.
|
||||||
@ -93,12 +159,16 @@ export async function scoreJobSuitability(
|
|||||||
getEffectiveSettings(),
|
getEffectiveSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const jobSearchProfile = settings.jobSearchProfile?.value ?? null;
|
||||||
|
const hasProfile = jobSearchProfile && hasNonEmptyProfile(jobSearchProfile);
|
||||||
|
|
||||||
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
|
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
|
||||||
instructions: settings.scoringInstructions?.value ?? "",
|
instructions: settings.scoringInstructions?.value ?? "",
|
||||||
|
jobSearchProfile: hasProfile ? jobSearchProfile : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const llm = new LlmService();
|
const llm = new LlmService();
|
||||||
const result = await llm.callJson<{ score: number; reason: string }>({
|
const result = await llm.callJson<ScoringLlmResponse>({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
jsonSchema: SCORING_SCHEMA,
|
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 data.score !== "number" || Number.isNaN(data.score)) {
|
||||||
if (typeof score !== "number" || Number.isNaN(score)) {
|
|
||||||
logger.error("Invalid score in AI response, using mock scoring", {
|
logger.error("Invalid score in AI response, using mock scoring", {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
@ -133,10 +202,10 @@ export async function scoreJobSuitability(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedScore = Math.min(100, Math.max(0, Math.round(score)));
|
const clampedScore = Math.min(100, Math.max(0, Math.round(data.score)));
|
||||||
const clampedReason = reason || "No explanation provided";
|
const clampedReason = data.reason || "No explanation provided";
|
||||||
|
const analysis = extractAnalysis(data);
|
||||||
|
|
||||||
// Apply salary penalty if enabled
|
|
||||||
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
|
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
|
||||||
penalizeMissingSalary: settings.penalizeMissingSalary.value,
|
penalizeMissingSalary: settings.penalizeMissingSalary.value,
|
||||||
missingSalaryPenalty: settings.missingSalaryPenalty.value,
|
missingSalaryPenalty: settings.missingSalaryPenalty.value,
|
||||||
@ -145,9 +214,20 @@ export async function scoreJobSuitability(
|
|||||||
return {
|
return {
|
||||||
score: penaltyResult.score,
|
score: penaltyResult.score,
|
||||||
reason: penaltyResult.reason,
|
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.
|
* Robustly parse JSON from AI-generated content.
|
||||||
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
||||||
@ -161,44 +241,33 @@ export function parseJsonFromContent(
|
|||||||
const originalContent = content;
|
const originalContent = content;
|
||||||
let candidate = content.trim();
|
let candidate = content.trim();
|
||||||
|
|
||||||
// Step 1: Remove markdown code fences (with or without language specifier)
|
|
||||||
candidate = stripMarkdownCodeFences(candidate);
|
candidate = stripMarkdownCodeFences(candidate);
|
||||||
|
|
||||||
// Step 2: Try to extract JSON object if there's surrounding text
|
|
||||||
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
candidate = jsonMatch[0];
|
candidate = jsonMatch[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Try direct parse first
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(candidate);
|
return JSON.parse(candidate);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue with sanitization
|
// Continue with sanitization
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Fix common JSON issues
|
|
||||||
let sanitized = candidate;
|
let sanitized = candidate;
|
||||||
|
|
||||||
// Remove JavaScript-style comments (// and /* */)
|
|
||||||
sanitized = sanitized.replace(/\/\/[^\n]*/g, "");
|
sanitized = sanitized.replace(/\/\/[^\n]*/g, "");
|
||||||
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, "");
|
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
|
||||||
// Remove trailing commas before } or ]
|
|
||||||
sanitized = sanitized.replace(/,\s*([\]}])/g, "$1");
|
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(
|
sanitized = sanitized.replace(
|
||||||
/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g,
|
/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g,
|
||||||
'$1"$2":',
|
'$1"$2":',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fix single quotes to double quotes
|
|
||||||
sanitized = sanitized.replace(/'/g, '"');
|
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
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed to fix broken JSON from AI
|
||||||
const controlCharsRegex = /[\x00-\x1F\x7F]/g;
|
const controlCharsRegex = /[\x00-\x1F\x7F]/g;
|
||||||
sanitized = sanitized.replace(controlCharsRegex, (match) => {
|
sanitized = sanitized.replace(controlCharsRegex, (match) => {
|
||||||
@ -208,15 +277,12 @@ export function parseJsonFromContent(
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Try parsing the sanitized version
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(sanitized);
|
return JSON.parse(sanitized);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue with more aggressive extraction
|
// 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(
|
const scoreMatch = originalContent.match(
|
||||||
/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i,
|
/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i,
|
||||||
);
|
);
|
||||||
@ -238,7 +304,6 @@ export function parseJsonFromContent(
|
|||||||
return { score, reason };
|
return { score, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the failure with full content for debugging
|
|
||||||
logger.error("Failed to parse AI response", {
|
logger.error("Failed to parse AI response", {
|
||||||
jobId: jobId || "unknown",
|
jobId: jobId || "unknown",
|
||||||
rawSample: originalContent.substring(0, 500),
|
rawSample: originalContent.substring(0, 500),
|
||||||
@ -253,16 +318,59 @@ function buildScoringPrompt(
|
|||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
preferences: ScoringPreferences,
|
preferences: ScoringPreferences,
|
||||||
): string {
|
): 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
|
- Skills match (technologies, frameworks, languages): 0-30 points
|
||||||
- Experience level match: 0-25 points
|
- Experience level match: 0-25 points
|
||||||
- Location/remote work alignment: 0-15 points
|
- Location/remote work alignment: 0-15 points
|
||||||
- Industry/domain fit: 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)}
|
${JSON.stringify(profile, null, 2)}
|
||||||
|
|
||||||
JOB LISTING:
|
JOB LISTING:
|
||||||
@ -286,13 +394,20 @@ ${
|
|||||||
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
||||||
|
|
||||||
REQUIRED FORMAT (exactly this structure):
|
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:
|
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>,
|
profile: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const p = profile as {
|
const p = profile as {
|
||||||
@ -328,7 +443,6 @@ async function mockScore(
|
|||||||
job: Job,
|
job: Job,
|
||||||
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
|
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
|
||||||
): Promise<SuitabilityResult> {
|
): Promise<SuitabilityResult> {
|
||||||
// Simple keyword-based scoring as fallback
|
|
||||||
const jd = (job.jobDescription || "").toLowerCase();
|
const jd = (job.jobDescription || "").toLowerCase();
|
||||||
const title = job.title.toLowerCase();
|
const title = job.title.toLowerCase();
|
||||||
|
|
||||||
@ -368,12 +482,12 @@ async function mockScore(
|
|||||||
|
|
||||||
const baseReason = "Scored using keyword matching (API key not configured)";
|
const baseReason = "Scored using keyword matching (API key not configured)";
|
||||||
|
|
||||||
// Apply salary penalty if enabled
|
|
||||||
const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);
|
const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score: penaltyResult.score,
|
score: penaltyResult.score,
|
||||||
reason: penaltyResult.reason,
|
reason: penaltyResult.reason,
|
||||||
|
analysis: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,15 +498,25 @@ export async function scoreAndRankJobs(
|
|||||||
jobs: Job[],
|
jobs: Job[],
|
||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
): Promise<
|
): Promise<
|
||||||
Array<Job & { suitabilityScore: number; suitabilityReason: string }>
|
Array<
|
||||||
|
Job & {
|
||||||
|
suitabilityScore: number;
|
||||||
|
suitabilityReason: string;
|
||||||
|
suitabilityAnalysis: string | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
> {
|
> {
|
||||||
const scoredJobs = await Promise.all(
|
const scoredJobs = await Promise.all(
|
||||||
jobs.map(async (job) => {
|
jobs.map(async (job) => {
|
||||||
const { score, reason } = await scoreJobSuitability(job, profile);
|
const { score, reason, analysis } = await scoreJobSuitability(
|
||||||
|
job,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
suitabilityScore: score,
|
suitabilityScore: score,
|
||||||
suitabilityReason: reason,
|
suitabilityReason: reason,
|
||||||
|
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -92,37 +92,40 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
});
|
});
|
||||||
let profile: Record<string, unknown> = {};
|
let profile: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (rxresumeBaseResumeId) {
|
try {
|
||||||
try {
|
profile = (await getProfile()) as Record<string, unknown>;
|
||||||
const resume = await getResume(rxresumeBaseResumeId);
|
} catch (error) {
|
||||||
if (resume.data && typeof resume.data === "object") {
|
logger.warn("Failed to load base resume profile for settings (primary)", {
|
||||||
profile = resume.data as Record<string, unknown>;
|
error,
|
||||||
}
|
});
|
||||||
} catch (error) {
|
if (rxresumeBaseResumeId) {
|
||||||
if (error instanceof RxResumeAuthConfigError) {
|
try {
|
||||||
logger.warn(
|
const resume = await getResume(rxresumeBaseResumeId);
|
||||||
"Reactive Resume credentials missing during settings load",
|
if (resume.data && typeof resume.data === "object") {
|
||||||
{
|
profile = resume.data as Record<string, unknown>;
|
||||||
resumeId: rxresumeBaseResumeId,
|
}
|
||||||
error,
|
} catch (rxError) {
|
||||||
},
|
if (rxError instanceof RxResumeAuthConfigError) {
|
||||||
);
|
logger.warn(
|
||||||
} else {
|
"Reactive Resume credentials missing during settings load",
|
||||||
logger.warn("Failed to load Reactive Resume base resume for settings", {
|
{
|
||||||
resumeId: rxresumeBaseResumeId,
|
resumeId: rxresumeBaseResumeId,
|
||||||
error,
|
error: rxError,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to load Reactive Resume base resume for settings",
|
||||||
|
{
|
||||||
|
resumeId: rxresumeBaseResumeId,
|
||||||
|
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);
|
const envSettings = await getEnvSettingsData(overrides);
|
||||||
|
|
||||||
const result: Partial<AppSettings> = {
|
const result: Partial<AppSettings> = {
|
||||||
@ -217,5 +220,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
// Always expose the effective base resume id for the active RxResume mode.
|
// Always expose the effective base resume id for the active RxResume mode.
|
||||||
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
|
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
|
||||||
|
|
||||||
|
result.localResumeFileConfigured =
|
||||||
|
Boolean(process.env.JOBOPS_LOCAL_RESUME_PATH?.trim()) ||
|
||||||
|
Boolean((overrides.localResumeProfilePath ?? "").trim());
|
||||||
|
|
||||||
return result as AppSettings;
|
return result as AppSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
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: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:3001",
|
target: "http://localhost:3001",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
inferCountryKeyFromSearchGeography,
|
||||||
matchesRequestedCity,
|
matchesRequestedCity,
|
||||||
parseSearchCitiesSetting,
|
parseSearchCitiesSetting,
|
||||||
resolveSearchCities,
|
resolveSearchCities,
|
||||||
@ -64,6 +65,18 @@ describe("search-cities", () => {
|
|||||||
).toEqual([]);
|
).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", () => {
|
it("applies strict filter only when city differs from country", () => {
|
||||||
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
|
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
|
||||||
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
|
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
|
||||||
|
|||||||
@ -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> = {
|
const LOCATION_ALIASES: Record<string, string> = {
|
||||||
uk: "united kingdom",
|
uk: "united kingdom",
|
||||||
@ -14,6 +19,23 @@ export function normalizeLocationToken(
|
|||||||
return LOCATION_ALIASES[normalized] ?? normalized;
|
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(
|
export function parseSearchCitiesSetting(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||||
type ChatStyleLanguageMode,
|
type ChatStyleLanguageMode,
|
||||||
type ChatStyleManualLanguage,
|
type ChatStyleManualLanguage,
|
||||||
|
type JobSearchProfile,
|
||||||
type ResumeProjectsSettings,
|
type ResumeProjectsSettings,
|
||||||
} from "./types/settings";
|
} from "./types/settings";
|
||||||
|
|
||||||
@ -130,14 +131,58 @@ const parseChatStyleManualLanguageOrNull = createEnumParser(
|
|||||||
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
|
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
|
||||||
const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES);
|
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({
|
export const resumeProjectsSchema = z.object({
|
||||||
maxProjects: z.number().int().min(0).max(100),
|
maxProjects: z.number().int().min(0).max(100),
|
||||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
aiSelectableProjectIds: 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 = {
|
export const settingsRegistry = {
|
||||||
// --- Typed Settings ---
|
// --- 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: {
|
model: {
|
||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
schema: z.string().trim().max(200),
|
schema: z.string().trim().max(200),
|
||||||
@ -535,6 +580,10 @@ export const settingsRegistry = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// --- Simple Strings ---
|
// --- Simple Strings ---
|
||||||
|
activeProfileId: {
|
||||||
|
kind: "string" as const,
|
||||||
|
schema: z.string().trim().max(200),
|
||||||
|
},
|
||||||
rxresumeBaseResumeId: {
|
rxresumeBaseResumeId: {
|
||||||
kind: "string" as const,
|
kind: "string" as const,
|
||||||
schema: z.string().trim().max(200),
|
schema: z.string().trim().max(200),
|
||||||
@ -560,6 +609,11 @@ export const settingsRegistry = {
|
|||||||
z.string().trim().url().max(2000).nullable(),
|
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: {
|
ukvisajobsEmail: {
|
||||||
kind: "string" as const,
|
kind: "string" as const,
|
||||||
envKey: "UKVISAJOBS_EMAIL",
|
envKey: "UKVISAJOBS_EMAIL",
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
closedAt: null,
|
closedAt: null,
|
||||||
suitabilityScore: 90,
|
suitabilityScore: 90,
|
||||||
suitabilityReason: "Strong fit",
|
suitabilityReason: "Strong fit",
|
||||||
|
suitabilityAnalysis: null,
|
||||||
|
coverLetter: null,
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
tailoredHeadline: null,
|
tailoredHeadline: null,
|
||||||
tailoredSkills: null,
|
tailoredSkills: null,
|
||||||
@ -125,6 +127,35 @@ export const createResumeProjectCatalogItem = (
|
|||||||
export const createAppSettings = (
|
export const createAppSettings = (
|
||||||
overrides: Partial<AppSettings> = {},
|
overrides: Partial<AppSettings> = {},
|
||||||
): 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 },
|
model: { value: "gpt-4o", default: "gpt-4o", override: null },
|
||||||
modelScorer: { value: "gpt-4o", override: null },
|
modelScorer: { value: "gpt-4o", override: null },
|
||||||
modelTailoring: { value: "gpt-4o", override: null },
|
modelTailoring: { value: "gpt-4o", override: null },
|
||||||
@ -147,6 +178,7 @@ export const createAppSettings = (
|
|||||||
},
|
},
|
||||||
override: null,
|
override: null,
|
||||||
},
|
},
|
||||||
|
activeProfileId: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
rxresumeBaseResumeIdV4: null,
|
rxresumeBaseResumeIdV4: null,
|
||||||
rxresumeBaseResumeIdV5: null,
|
rxresumeBaseResumeIdV5: null,
|
||||||
@ -213,6 +245,7 @@ export const createAppSettings = (
|
|||||||
rxresumeApiKeyHint: null,
|
rxresumeApiKeyHint: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
rxresumeUrl: null,
|
rxresumeUrl: null,
|
||||||
|
localResumeProfilePath: null,
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
basicAuthPasswordHint: null,
|
basicAuthPasswordHint: null,
|
||||||
@ -222,6 +255,7 @@ export const createAppSettings = (
|
|||||||
adzunaAppKeyHint: null,
|
adzunaAppKeyHint: null,
|
||||||
webhookSecretHint: null,
|
webhookSecretHint: null,
|
||||||
basicAuthActive: false,
|
basicAuthActive: false,
|
||||||
|
localResumeFileConfigured: false,
|
||||||
backupEnabled: { value: false, default: false, override: null },
|
backupEnabled: { value: false, default: false, override: null },
|
||||||
backupHour: { value: 3, default: 3, override: null },
|
backupHour: { value: 3, default: 3, override: null },
|
||||||
backupMaxCount: { value: 7, default: 7, override: null },
|
backupMaxCount: { value: 7, default: 7, override: null },
|
||||||
|
|||||||
@ -148,12 +148,14 @@ export interface Job {
|
|||||||
closedAt: number | null;
|
closedAt: number | null;
|
||||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||||
suitabilityReason: string | null; // AI explanation
|
suitabilityReason: string | null; // AI explanation
|
||||||
|
suitabilityAnalysis: string | null; // JSON-encoded SuitabilityAnalysis
|
||||||
tailoredSummary: string | null; // Generated resume summary
|
tailoredSummary: string | null; // Generated resume summary
|
||||||
tailoredHeadline: string | null; // Generated resume headline
|
tailoredHeadline: string | null; // Generated resume headline
|
||||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||||
pdfPath: string | null; // Path to generated PDF
|
pdfPath: string | null; // Path to generated PDF
|
||||||
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
|
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
|
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||||
|
|
||||||
@ -305,6 +307,7 @@ export interface UpdateJobInput {
|
|||||||
jobDescription?: string | null;
|
jobDescription?: string | null;
|
||||||
suitabilityScore?: number;
|
suitabilityScore?: number;
|
||||||
suitabilityReason?: string;
|
suitabilityReason?: string;
|
||||||
|
suitabilityAnalysis?: string;
|
||||||
tailoredSummary?: string;
|
tailoredSummary?: string;
|
||||||
tailoredHeadline?: string;
|
tailoredHeadline?: string;
|
||||||
tailoredSkills?: string;
|
tailoredSkills?: string;
|
||||||
@ -312,6 +315,7 @@ export interface UpdateJobInput {
|
|||||||
pdfPath?: string;
|
pdfPath?: string;
|
||||||
tracerLinksEnabled?: boolean;
|
tracerLinksEnabled?: boolean;
|
||||||
appliedAt?: string;
|
appliedAt?: string;
|
||||||
|
coverLetter?: string | null;
|
||||||
sponsorMatchScore?: number;
|
sponsorMatchScore?: number;
|
||||||
sponsorMatchNames?: string;
|
sponsorMatchNames?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,11 +42,15 @@ export interface JobsRevisionResponse {
|
|||||||
statusFilter: string | null;
|
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 =
|
export type JobActionRequest =
|
||||||
| {
|
| {
|
||||||
action: "skip" | "rescore";
|
action: "skip" | "rescore" | "generate_cover_letter";
|
||||||
jobIds: string[];
|
jobIds: string[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@ -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 {
|
export interface ResumeProjectCatalogItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -144,6 +184,7 @@ export type ModelResolved = { value: string; override: string | null };
|
|||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
// Typed settings (Resolved):
|
// Typed settings (Resolved):
|
||||||
|
jobSearchProfile: Resolved<JobSearchProfile>;
|
||||||
model: Resolved<string>;
|
model: Resolved<string>;
|
||||||
llmProvider: Resolved<string>;
|
llmProvider: Resolved<string>;
|
||||||
llmBaseUrl: Resolved<string>;
|
llmBaseUrl: Resolved<string>;
|
||||||
@ -183,11 +224,14 @@ export interface AppSettings {
|
|||||||
modelProjectSelection: ModelResolved;
|
modelProjectSelection: ModelResolved;
|
||||||
|
|
||||||
// Simple strings:
|
// Simple strings:
|
||||||
|
activeProfileId: string | null;
|
||||||
rxresumeBaseResumeId: string | null;
|
rxresumeBaseResumeId: string | null;
|
||||||
rxresumeBaseResumeIdV4: string | null;
|
rxresumeBaseResumeIdV4: string | null;
|
||||||
rxresumeBaseResumeIdV5: string | null;
|
rxresumeBaseResumeIdV5: string | null;
|
||||||
rxresumeEmail: string | null;
|
rxresumeEmail: string | null;
|
||||||
rxresumeUrl: string | null;
|
rxresumeUrl: string | null;
|
||||||
|
/** Path to local Reactive Resume JSON (see JOBOPS_LOCAL_RESUME_PATH). */
|
||||||
|
localResumeProfilePath: string | null;
|
||||||
ukvisajobsEmail: string | null;
|
ukvisajobsEmail: string | null;
|
||||||
adzunaAppId: string | null;
|
adzunaAppId: string | null;
|
||||||
basicAuthUser: string | null;
|
basicAuthUser: string | null;
|
||||||
@ -203,5 +247,7 @@ export interface AppSettings {
|
|||||||
|
|
||||||
// Computed:
|
// Computed:
|
||||||
basicAuthActive: boolean;
|
basicAuthActive: boolean;
|
||||||
|
/** True when JOBOPS_LOCAL_RESUME_PATH is set on the server (not shown in UI). */
|
||||||
|
localResumeFileConfigured: boolean;
|
||||||
profileProjects: ResumeProjectCatalogItem[];
|
profileProjects: ResumeProjectCatalogItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user