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
|
||||
# RXRESUME_URL=
|
||||
|
||||
# Optional: load resume JSON from disk instead of the RxResume API (scoring, tailoring, cover letters).
|
||||
# Path is absolute or relative to the orchestrator process cwd (often `orchestrator/` when using `npm run dev` there).
|
||||
# Takes precedence over Settings → local path. PDF export still uses RxResume when enabled.
|
||||
# Example (monorepo): hand-authored v5 JSON may live under `data/resumes/` (that folder is gitignored by default).
|
||||
# JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json
|
||||
|
||||
# RXResume credentials for PDF generation
|
||||
# Create an account at: https://v4.rxresu.me
|
||||
RXRESUME_EMAIL=your_email@example.com
|
||||
@ -24,8 +30,10 @@ BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
# Optional: client build only — skip RxResume steps in the onboarding wizard (search without PDF export).
|
||||
# - Local dev: set here or export before `npm run dev` / `npm run build:client` in orchestrator.
|
||||
# - Docker: set at IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env.
|
||||
# Prefer setting `JOBOPS_LOCAL_RESUME_PATH` above: the API tells the UI to skip RxResume onboarding automatically.
|
||||
# Otherwise: copy `orchestrator/.env.example` → `orchestrator/.env` and set VITE_SKIP_RXRESUME_ONBOARDING=true
|
||||
# (Vite only reads `orchestrator/.env`, not this root file.)
|
||||
# Docker: Vite vars need IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env.
|
||||
# VITE_SKIP_RXRESUME_ONBOARDING=true
|
||||
|
||||
# Public base URL used to generate tracer links when PDFs are created by
|
||||
|
||||
@ -9,8 +9,12 @@
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/dist",
|
||||
"!!**/.venv",
|
||||
"!!docs-site/.docusaurus",
|
||||
"!!docs-site/build"
|
||||
"!!docs-site/build",
|
||||
"!!extractors/jobspy/storage",
|
||||
"!!orchestrator/storage",
|
||||
"!!data"
|
||||
]
|
||||
},
|
||||
"css": {
|
||||
|
||||
2
extractors/jobspy/.gitignore
vendored
Normal file
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
|
||||
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/
|
||||
|
||||
# Extractor / Apify-style KV written under cwd during pipeline runs
|
||||
storage/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@ -20,6 +20,7 @@ import type {
|
||||
JobChatThread,
|
||||
JobListItem,
|
||||
JobOutcome,
|
||||
JobSearchProfile,
|
||||
JobSource,
|
||||
JobsListResponse,
|
||||
JobsRevisionResponse,
|
||||
@ -39,6 +40,7 @@ import type {
|
||||
ResumeProfile,
|
||||
ResumeProjectCatalogItem,
|
||||
RxResumeMode,
|
||||
SearchProfile,
|
||||
StageEvent,
|
||||
StageEventMetadata,
|
||||
StageTransitionTarget,
|
||||
@ -1509,3 +1511,46 @@ export async function deleteBackup(filename: string): Promise<void> {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Profiles API
|
||||
export async function listProfiles(): Promise<SearchProfile[]> {
|
||||
return fetchApi<SearchProfile[]>("/profiles");
|
||||
}
|
||||
|
||||
export async function createProfile(input: {
|
||||
name: string;
|
||||
data: JobSearchProfile;
|
||||
}): Promise<SearchProfile> {
|
||||
return fetchApi<SearchProfile>("/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
id: string,
|
||||
input: { name?: string; data?: JobSearchProfile },
|
||||
): Promise<SearchProfile> {
|
||||
return fetchApi<SearchProfile>(`/profiles/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProfile(id: string): Promise<void> {
|
||||
await fetchApi<void>(`/profiles/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateProfile(id: string): Promise<void> {
|
||||
await fetchApi<void>(`/profiles/${id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateProfileFromResume(): Promise<JobSearchProfile> {
|
||||
return fetchApi<JobSearchProfile>("/profiles/generate-from-resume", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
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 { Sparkles } from "lucide-react";
|
||||
import type { Job, SuitabilityAnalysis } from "@shared/types.js";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FitAssessmentProps {
|
||||
@ -8,23 +15,156 @@ interface FitAssessmentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseAnalysis(raw: string | null): SuitabilityAnalysis | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && Array.isArray(parsed.strengths))
|
||||
return parsed as SuitabilityAnalysis;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RoleMatchBadge({ score }: { score: number }) {
|
||||
const color =
|
||||
score >= 70
|
||||
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
: score >= 40
|
||||
? "text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
: "text-red-500 bg-red-500/10 border-red-500/20";
|
||||
|
||||
const label =
|
||||
score >= 70
|
||||
? "Strong role match"
|
||||
: score >= 40
|
||||
? "Partial role match"
|
||||
: "Weak role match";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
{label} ({score}%)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const FitAssessment: React.FC<FitAssessmentProps> = ({
|
||||
job,
|
||||
className,
|
||||
}) => {
|
||||
if (!job.suitabilityReason) return null;
|
||||
const analysis = useMemo(
|
||||
() => parseAnalysis(job.suitabilityAnalysis ?? null),
|
||||
[job.suitabilityAnalysis],
|
||||
);
|
||||
|
||||
if (!job.suitabilityReason && !analysis) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* Summary / Reason */}
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-primary/70 mb-1.5 flex items-center gap-1.5">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Fit Assessment
|
||||
{analysis && <RoleMatchBadge score={analysis.roleTypeMatch} />}
|
||||
</div>
|
||||
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
||||
{job.suitabilityReason}
|
||||
</p>
|
||||
{job.suitabilityReason && (
|
||||
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
||||
{job.suitabilityReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{/* Deal-Breaker Hits */}
|
||||
{analysis.dealBreakerHits.length > 0 && (
|
||||
<div className="sm:col-span-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-destructive/80 mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Deal-breakers triggered
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{analysis.dealBreakerHits.map((hit) => (
|
||||
<li
|
||||
key={hit}
|
||||
className="text-xs text-destructive/90 flex items-start gap-1.5"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
{hit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strengths */}
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-emerald-600/80 dark:text-emerald-400/80 mb-1 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Strengths
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{analysis.strengths.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className="text-xs text-foreground/80 leading-relaxed"
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gaps */}
|
||||
{analysis.gaps.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-amber-600/80 dark:text-amber-400/80 mb-1 flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Gaps
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{analysis.gaps.map((g) => (
|
||||
<li
|
||||
key={g}
|
||||
className="text-xs text-foreground/80 leading-relaxed"
|
||||
>
|
||||
{g}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<div className="sm:col-span-2 rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-blue-600/80 dark:text-blue-400/80 mb-1 flex items-center gap-1">
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
Suggestions to improve fit
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{analysis.suggestions.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className="text-xs text-foreground/80 leading-relaxed"
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -110,6 +110,8 @@ const settingsResponse = {
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumePasswordHint: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
localResumeProfilePath: null,
|
||||
localResumeFileConfigured: false,
|
||||
},
|
||||
isLoading: false,
|
||||
refreshSettings: vi.fn(),
|
||||
@ -178,6 +180,25 @@ describe("OnboardingGate", () => {
|
||||
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the gate for Ollama when local resume file is configured on the server", async () => {
|
||||
vi.mocked(useSettings).mockReturnValue({
|
||||
...settingsResponse,
|
||||
settings: {
|
||||
...settingsResponse.settings,
|
||||
llmProvider: { value: "ollama", default: "ollama", override: null },
|
||||
localResumeFileConfigured: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(api.validateRxresume).not.toHaveBeenCalled();
|
||||
expect(api.validateResumeConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips LLM key validation for providers without API keys", async () => {
|
||||
vi.mocked(useSettings).mockReturnValue({
|
||||
...settingsResponse,
|
||||
|
||||
@ -94,14 +94,20 @@ function getStepPrimaryLabel(input: {
|
||||
}
|
||||
|
||||
export const OnboardingGate: React.FC = () => {
|
||||
/** Opt-in: set `VITE_SKIP_RXRESUME_ONBOARDING=true` at build/dev time to skip RxResume steps in onboarding. */
|
||||
const skipRxResumeOnboarding =
|
||||
import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true";
|
||||
const {
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
refreshSettings,
|
||||
} = useSettings();
|
||||
|
||||
/** Skip RxResume onboarding when Vite flag is set, server reports a local resume file, or Settings has a local path. */
|
||||
const skipRxResumeOnboarding = useMemo(
|
||||
() =>
|
||||
import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true" ||
|
||||
Boolean(settings?.localResumeFileConfigured) ||
|
||||
Boolean(settings?.localResumeProfilePath?.trim()),
|
||||
[settings?.localResumeFileConfigured, settings?.localResumeProfilePath],
|
||||
);
|
||||
const {
|
||||
storedRxResume,
|
||||
getBaseResumeIdForMode,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export { CoverLetterDisplay } from "./CoverLetterDisplay";
|
||||
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
|
||||
export { FitAssessment } from "./FitAssessment";
|
||||
export { JobHeader } from "./JobHeader";
|
||||
|
||||
@ -186,6 +186,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
canSkipSelected,
|
||||
canMoveSelected,
|
||||
canRescoreSelected,
|
||||
canGenerateCoverLetter,
|
||||
jobActionInFlight,
|
||||
toggleSelectJob,
|
||||
toggleSelectAll,
|
||||
@ -437,10 +438,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
canMoveSelected={canMoveSelected}
|
||||
canSkipSelected={canSkipSelected}
|
||||
canRescoreSelected={canRescoreSelected}
|
||||
canGenerateCoverLetter={canGenerateCoverLetter}
|
||||
jobActionInFlight={jobActionInFlight !== null}
|
||||
onMoveToReady={() => void runJobAction("move_to_ready")}
|
||||
onSkipSelected={() => void runJobAction("skip")}
|
||||
onRescoreSelected={() => void runJobAction("rescore")}
|
||||
onGenerateCoverLetter={() => void runJobAction("generate_cover_letter")}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import { ChatSettingsSection } from "@client/pages/settings/components/ChatSetti
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||
import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection";
|
||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||
@ -60,6 +61,7 @@ import { Accordion } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
jobSearchProfile: null,
|
||||
model: "",
|
||||
modelScorer: "",
|
||||
modelTailoring: "",
|
||||
@ -82,6 +84,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumeUrl: "",
|
||||
localResumeProfilePath: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: "",
|
||||
@ -137,6 +140,7 @@ const normalizeLlmProviderValue = (
|
||||
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
||||
|
||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
jobSearchProfile: null,
|
||||
model: null,
|
||||
modelScorer: null,
|
||||
modelTailoring: null,
|
||||
@ -159,6 +163,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumeUrl: null,
|
||||
localResumeProfilePath: null,
|
||||
rxresumePassword: null,
|
||||
rxresumeApiKey: null,
|
||||
basicAuthUser: null,
|
||||
@ -181,6 +186,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
};
|
||||
|
||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
jobSearchProfile: data.jobSearchProfile?.override ?? null,
|
||||
model: data.model.override ?? "",
|
||||
modelScorer: data.modelScorer.override ?? "",
|
||||
modelTailoring: data.modelTailoring.override ?? "",
|
||||
@ -204,6 +210,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumeUrl: data.rxresumeUrl ?? "",
|
||||
localResumeProfilePath: data.localResumeProfilePath ?? "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
@ -370,6 +377,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
default: settings?.backupMaxCount?.default ?? 5,
|
||||
},
|
||||
},
|
||||
jobSearchProfile: {
|
||||
effective: settings?.jobSearchProfile?.value ?? null,
|
||||
default: settings?.jobSearchProfile?.default ?? null,
|
||||
},
|
||||
scoring: {
|
||||
penalizeMissingSalary: {
|
||||
effective: settings?.penalizeMissingSalary?.value ?? false,
|
||||
@ -572,6 +583,7 @@ export const SettingsPage: React.FC = () => {
|
||||
profileProjects,
|
||||
backup,
|
||||
scoring,
|
||||
jobSearchProfile,
|
||||
} = derived;
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
@ -769,6 +781,12 @@ export const SettingsPage: React.FC = () => {
|
||||
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
|
||||
}
|
||||
|
||||
if (dirtyFields.localResumeProfilePath) {
|
||||
envPayload.localResumeProfilePath = normalizeString(
|
||||
data.localResumeProfilePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
||||
}
|
||||
@ -833,7 +851,14 @@ export const SettingsPage: React.FC = () => {
|
||||
if (value !== undefined) envPayload.webhookSecret = value;
|
||||
}
|
||||
|
||||
const jobSearchProfilePayload = dirtyFields.jobSearchProfile
|
||||
? data.jobSearchProfile
|
||||
: undefined;
|
||||
|
||||
const payload: Partial<UpdateSettingsInput> = {
|
||||
...(jobSearchProfilePayload !== undefined
|
||||
? { jobSearchProfile: jobSearchProfilePayload }
|
||||
: {}),
|
||||
model: dirtyFields.llmProvider
|
||||
? dirtyFields.model
|
||||
? normalizeString(data.model)
|
||||
@ -986,6 +1011,21 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const updated = await updateSettingsMutation.mutateAsync(payload);
|
||||
|
||||
if (
|
||||
dirtyFields.jobSearchProfile &&
|
||||
data.jobSearchProfile &&
|
||||
updated.activeProfileId
|
||||
) {
|
||||
try {
|
||||
await api.updateProfile(updated.activeProfileId, {
|
||||
data: data.jobSearchProfile,
|
||||
});
|
||||
} catch {
|
||||
// Profile sync is best-effort; settings are already saved
|
||||
}
|
||||
}
|
||||
|
||||
setSettings(updated);
|
||||
reset(mapSettingsToForm(updated));
|
||||
toast.success("Settings saved");
|
||||
@ -1167,6 +1207,12 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobSearchProfileSection
|
||||
values={jobSearchProfile}
|
||||
activeProfileId={settings?.activeProfileId ?? null}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ScoringSettingsSection
|
||||
values={scoring}
|
||||
isLoading={isLoading}
|
||||
|
||||
@ -7,10 +7,12 @@ interface FloatingJobActionsBarProps {
|
||||
canMoveSelected: boolean;
|
||||
canSkipSelected: boolean;
|
||||
canRescoreSelected: boolean;
|
||||
canGenerateCoverLetter: boolean;
|
||||
jobActionInFlight: boolean;
|
||||
onMoveToReady: () => void;
|
||||
onSkipSelected: () => void;
|
||||
onRescoreSelected: () => void;
|
||||
onGenerateCoverLetter: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
@ -19,10 +21,12 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
canMoveSelected,
|
||||
canSkipSelected,
|
||||
canRescoreSelected,
|
||||
canGenerateCoverLetter,
|
||||
jobActionInFlight,
|
||||
onMoveToReady,
|
||||
onSkipSelected,
|
||||
onRescoreSelected,
|
||||
onGenerateCoverLetter,
|
||||
onClear,
|
||||
}) => {
|
||||
return (
|
||||
@ -76,6 +80,18 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
Recalculate match
|
||||
</Button>
|
||||
)}
|
||||
{canGenerateCoverLetter && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onGenerateCoverLetter}
|
||||
>
|
||||
Cover letter
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@ -52,6 +52,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
});
|
||||
|
||||
vi.mock("@client/components", () => ({
|
||||
CoverLetterDisplay: () => <div data-testid="cover-letter-display" />,
|
||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||
),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as api from "@client/api";
|
||||
import {
|
||||
CoverLetterDisplay,
|
||||
DiscoveredPanel,
|
||||
FitAssessment,
|
||||
JobHeader,
|
||||
@ -601,6 +602,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
<FitAssessment job={selectedJob} />
|
||||
<CoverLetterDisplay job={selectedJob} />
|
||||
<TailoredSummary job={selectedJob} />
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
|
||||
@ -16,6 +16,10 @@ export function canRescore(jobs: JobListItem[]): boolean {
|
||||
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
|
||||
}
|
||||
|
||||
export function canGenerateCoverLetter(jobs: JobListItem[]): boolean {
|
||||
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
|
||||
}
|
||||
|
||||
export function getFailedJobIds(response: JobActionResponse): Set<string> {
|
||||
const failedIds = response.results
|
||||
.filter((result) => !result.ok)
|
||||
|
||||
@ -10,6 +10,7 @@ import { trackProductEvent } from "@/lib/analytics";
|
||||
import type { FilterTab } from "./constants";
|
||||
import { JobActionProgressToast } from "./JobActionProgressToast";
|
||||
import {
|
||||
canGenerateCoverLetter,
|
||||
canMoveToReady,
|
||||
canRescore,
|
||||
canSkip,
|
||||
@ -23,12 +24,14 @@ const jobActionLabel: Record<JobAction, string> = {
|
||||
move_to_ready: "Moving jobs to Ready...",
|
||||
skip: "Skipping selected jobs...",
|
||||
rescore: "Calculating match scores...",
|
||||
generate_cover_letter: "Generating cover letters...",
|
||||
};
|
||||
|
||||
const jobActionSuccessLabel: Record<JobAction, string> = {
|
||||
move_to_ready: "jobs moved to Ready",
|
||||
skip: "jobs skipped",
|
||||
rescore: "matches recalculated",
|
||||
generate_cover_letter: "cover letters generated",
|
||||
};
|
||||
|
||||
interface UseJobSelectionActionsArgs {
|
||||
@ -64,6 +67,10 @@ export function useJobSelectionActions({
|
||||
() => canRescore(selectedJobs),
|
||||
[selectedJobs],
|
||||
);
|
||||
const canGenerateCoverLetterSelected = useMemo(
|
||||
() => canGenerateCoverLetter(selectedJobs),
|
||||
[selectedJobs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousActiveTabRef.current === activeTab) return;
|
||||
@ -283,6 +290,7 @@ export function useJobSelectionActions({
|
||||
canSkipSelected,
|
||||
canMoveSelected,
|
||||
canRescoreSelected,
|
||||
canGenerateCoverLetter: canGenerateCoverLetterSelected,
|
||||
jobActionInFlight,
|
||||
toggleSelectJob,
|
||||
toggleSelectAll,
|
||||
|
||||
@ -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,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type ReactiveResumeSectionProps = {
|
||||
rxResumeBaseResumeIdDraft: string | null;
|
||||
@ -73,6 +75,8 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
|
||||
const rxresumePasswordValue =
|
||||
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
||||
const localResumeProfilePathValue =
|
||||
useWatch({ control, name: "localResumeProfilePath" }) ?? "";
|
||||
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
||||
const setDirtyTouchedValue = <TField extends Path<UpdateSettingsInput>>(
|
||||
field: TField,
|
||||
@ -97,7 +101,31 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Reactive Resume</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<AccordionContent className="pb-4 space-y-6">
|
||||
<div className="space-y-2 rounded-md border border-dashed p-3">
|
||||
<Label htmlFor="local-resume-profile-path">
|
||||
Local resume JSON (no API)
|
||||
</Label>
|
||||
<Input
|
||||
id="local-resume-profile-path"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="/absolute/or/relative/path/to/resume.json"
|
||||
value={localResumeProfilePathValue}
|
||||
disabled={isLoading || isSaving}
|
||||
onChange={(e) =>
|
||||
setDirtyTouchedValue("localResumeProfilePath", e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Reactive Resume export JSON on this machine. When set, it overrides
|
||||
the RxResume API for scoring, cover letters, and PDF tailoring.
|
||||
Relative paths use the server process working directory. You can
|
||||
instead set{" "}
|
||||
<span className="font-mono">JOBOPS_LOCAL_RESUME_PATH</span> in{" "}
|
||||
<span className="font-mono">.env</span> (wins over this field).
|
||||
</p>
|
||||
</div>
|
||||
<ReactiveResumeConfigPanel
|
||||
mode={selectedMode}
|
||||
onModeChange={(mode) => {
|
||||
|
||||
@ -14,6 +14,7 @@ import { pipelineRouter } from "./routes/pipeline";
|
||||
import { postApplicationProvidersRouter } from "./routes/post-application-providers";
|
||||
import { postApplicationReviewRouter } from "./routes/post-application-review";
|
||||
import { profileRouter } from "./routes/profile";
|
||||
import { profilesRouter } from "./routes/profiles";
|
||||
import { settingsRouter } from "./routes/settings";
|
||||
import { tracerLinksRouter } from "./routes/tracer-links";
|
||||
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
||||
@ -31,6 +32,7 @@ apiRouter.use("/post-application", postApplicationReviewRouter);
|
||||
apiRouter.use("/manual-jobs", manualJobsRouter);
|
||||
apiRouter.use("/webhook", webhookRouter);
|
||||
apiRouter.use("/profile", profileRouter);
|
||||
apiRouter.use("/profiles", profilesRouter);
|
||||
apiRouter.use("/database", databaseRouter);
|
||||
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||
apiRouter.use("/onboarding", onboardingRouter);
|
||||
|
||||
@ -558,6 +558,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||
score: 81,
|
||||
reason: "Updated fit from action rescore",
|
||||
analysis: null,
|
||||
});
|
||||
|
||||
const discovered = await createJob({
|
||||
@ -754,6 +755,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||
score: 77,
|
||||
reason: "Updated fit",
|
||||
analysis: null,
|
||||
});
|
||||
|
||||
const job = await createJob({
|
||||
|
||||
@ -210,6 +210,10 @@ const jobActionRequestSchema = z.discriminatedUnion("action", [
|
||||
action: z.literal("rescore"),
|
||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("generate_cover_letter"),
|
||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("move_to_ready"),
|
||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||
@ -412,48 +416,104 @@ async function executeJobActionForJob(
|
||||
return { jobId, ok: true, job: updated };
|
||||
}
|
||||
|
||||
if (job.status === "processing") {
|
||||
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
||||
jobId,
|
||||
status: job.status,
|
||||
disallowedStatus: "processing",
|
||||
if (action === "rescore") {
|
||||
if (job.status === "processing") {
|
||||
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
||||
jobId,
|
||||
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()) {
|
||||
const simulated = await simulateRescoreJob(job.id);
|
||||
return { jobId, ok: true, job: simulated };
|
||||
if (action === "generate_cover_letter") {
|
||||
if (job.status === "processing") {
|
||||
throw badRequest(
|
||||
`Cannot generate cover letter while job is processing`,
|
||||
{ jobId, status: job.status },
|
||||
);
|
||||
}
|
||||
|
||||
const { generateCoverLetter } = await import(
|
||||
"@server/services/cover-letter"
|
||||
);
|
||||
|
||||
const resumeProfile = await (async () => {
|
||||
const rawProfile = await getProfile();
|
||||
if (
|
||||
!rawProfile ||
|
||||
typeof rawProfile !== "object" ||
|
||||
Array.isArray(rawProfile)
|
||||
) {
|
||||
throw badRequest("Invalid resume profile format");
|
||||
}
|
||||
return rawProfile as Record<string, unknown>;
|
||||
})();
|
||||
|
||||
const { getEffectiveSettings } = await import(
|
||||
"@server/services/settings"
|
||||
);
|
||||
const effectiveSettings = await getEffectiveSettings();
|
||||
const searchProfile = effectiveSettings.jobSearchProfile?.value ?? null;
|
||||
|
||||
const { coverLetter } = await generateCoverLetter(
|
||||
job,
|
||||
resumeProfile,
|
||||
searchProfile,
|
||||
);
|
||||
|
||||
const updated = await jobsRepo.updateJob(job.id, { coverLetter });
|
||||
if (!updated) {
|
||||
throw new AppError({
|
||||
status: 404,
|
||||
code: "NOT_FOUND",
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
return { jobId, ok: true, job: updated };
|
||||
}
|
||||
|
||||
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 } = 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 };
|
||||
throw badRequest(`Unknown action: ${action}`);
|
||||
} catch (error) {
|
||||
const mapped = mapErrorForResult(error);
|
||||
return {
|
||||
|
||||
@ -70,6 +70,7 @@ describe.sequential("Manual jobs API routes", () => {
|
||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||
score: 88,
|
||||
reason: "Strong fit",
|
||||
analysis: null,
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
|
||||
|
||||
@ -269,13 +269,14 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
|
||||
throw new Error("Invalid resume profile format");
|
||||
}
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { score, reason } = await scoreJobSuitability(
|
||||
const { score, reason, analysis } = await scoreJobSuitability(
|
||||
processedJob,
|
||||
profile,
|
||||
);
|
||||
await jobsRepo.updateJob(processedJob.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Manual job scoring failed", {
|
||||
|
||||
@ -509,7 +509,9 @@ describe.sequential("Onboarding API routes", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("No base resume selected");
|
||||
expect(String(body.data.message)).toMatch(
|
||||
/base resume|local resume|JOBOPS_LOCAL|reactive resume|api key|not configured/i,
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Further validation tests require mocking getSetting and getResume
|
||||
|
||||
@ -3,13 +3,12 @@ import { logger } from "@infra/logger";
|
||||
import { isDemoMode } from "@server/config/demo";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { LlmService } from "@server/services/llm/service";
|
||||
import { getProfile } from "@server/services/profile";
|
||||
import {
|
||||
getResume,
|
||||
RxResumeAuthConfigError,
|
||||
validateResumeSchema,
|
||||
validateCredentials as validateRxResumeCredentials,
|
||||
} from "@server/services/rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
export const onboardingRouter = Router();
|
||||
@ -80,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> {
|
||||
try {
|
||||
// Check if rxresumeBaseResumeId is configured
|
||||
const { resumeId: rxresumeBaseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
const profile = await getProfile();
|
||||
const validated = await validateResumeSchema(
|
||||
profile as unknown as Record<string, unknown>,
|
||||
);
|
||||
if (validated.ok) {
|
||||
return { valid: true, message: null };
|
||||
}
|
||||
return { valid: false, message: validated.message };
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
"No base resume selected. Please select a resume from your RxResume account in Settings.",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 =
|
||||
error instanceof Error ? error.message : "Resume validation failed.";
|
||||
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_names TEXT`,
|
||||
|
||||
// Add suitability analysis column for rich fit assessment
|
||||
`ALTER TABLE jobs ADD COLUMN suitability_analysis TEXT`,
|
||||
|
||||
// Add cover letter column for AI-generated cover letters
|
||||
`ALTER TABLE jobs ADD COLUMN cover_letter TEXT`,
|
||||
|
||||
// Create search profiles table for multi-profile support
|
||||
`CREATE TABLE IF NOT EXISTS search_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
|
||||
// Add application tracking columns
|
||||
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
|
||||
@ -436,6 +451,8 @@ const migrations = [
|
||||
closed_at INTEGER,
|
||||
suitability_score REAL,
|
||||
suitability_reason TEXT,
|
||||
suitability_analysis TEXT,
|
||||
cover_letter TEXT,
|
||||
tailored_summary TEXT,
|
||||
tailored_headline TEXT,
|
||||
tailored_skills TEXT,
|
||||
@ -457,7 +474,7 @@ const migrations = [
|
||||
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
||||
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||
applied_at, created_at, updated_at
|
||||
)
|
||||
@ -468,7 +485,7 @@ const migrations = [
|
||||
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
||||
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||
applied_at, created_at, updated_at
|
||||
FROM jobs`,
|
||||
|
||||
@ -93,6 +93,7 @@ export const jobs = sqliteTable("jobs", {
|
||||
closedAt: integer("closed_at", { mode: "number" }),
|
||||
suitabilityScore: real("suitability_score"),
|
||||
suitabilityReason: text("suitability_reason"),
|
||||
suitabilityAnalysis: text("suitability_analysis"),
|
||||
tailoredSummary: text("tailored_summary"),
|
||||
tailoredHeadline: text("tailored_headline"),
|
||||
tailoredSkills: text("tailored_skills"),
|
||||
@ -101,6 +102,7 @@ export const jobs = sqliteTable("jobs", {
|
||||
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
coverLetter: text("cover_letter"),
|
||||
sponsorMatchScore: real("sponsor_match_score"),
|
||||
sponsorMatchNames: text("sponsor_match_names"),
|
||||
|
||||
@ -249,6 +251,14 @@ export const jobChatRuns = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const searchProfiles = sqliteTable("search_profiles", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
data: text("data").notNull(),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
@ -454,6 +464,8 @@ export type JobChatMessageRow = typeof jobChatMessages.$inferSelect;
|
||||
export type NewJobChatMessageRow = typeof jobChatMessages.$inferInsert;
|
||||
export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
|
||||
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
|
||||
export type SearchProfileRow = typeof searchProfiles.$inferSelect;
|
||||
export type NewSearchProfileRow = typeof searchProfiles.$inferInsert;
|
||||
export type SettingsRow = typeof settings.$inferSelect;
|
||||
export type NewSettingsRow = typeof settings.$inferInsert;
|
||||
export type PostApplicationIntegrationRow =
|
||||
|
||||
@ -87,7 +87,11 @@ describe("Sponsor Match Calculation", () => {
|
||||
createJobs = jobsRepo.createJobs as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Default mock implementations
|
||||
scoreJobSuitability.mockResolvedValue({ score: 75, reason: "Good match" });
|
||||
scoreJobSuitability.mockResolvedValue({
|
||||
score: 75,
|
||||
reason: "Good match",
|
||||
analysis: null,
|
||||
});
|
||||
createJobs.mockResolvedValue({ created: 0, skipped: 0 });
|
||||
updateJob.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@ -92,6 +92,56 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("aligns JobSpy Indeed country to country-level search geography when settings disagree", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const registryModule = await import("@server/extractors/registry");
|
||||
|
||||
const jobspyManifest = {
|
||||
id: "jobspy",
|
||||
displayName: "JobSpy",
|
||||
providesSources: ["indeed", "linkedin", "glassdoor"],
|
||||
run: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
source: "linkedin",
|
||||
title: "Engineer",
|
||||
employer: "ACME",
|
||||
jobUrl: "https://example.com/job",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
searchCities: "UK",
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as any);
|
||||
|
||||
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
|
||||
manifests: new Map([["jobspy", jobspyManifest as any]]),
|
||||
manifestBySource: new Map([
|
||||
["indeed", jobspyManifest as any],
|
||||
["linkedin", jobspyManifest as any],
|
||||
["glassdoor", jobspyManifest as any],
|
||||
]),
|
||||
availableSources: ["indeed", "linkedin", "glassdoor"],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: { ...baseConfig, sources: ["indeed", "linkedin"] },
|
||||
});
|
||||
|
||||
expect(jobspyManifest.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when all enabled sources fail", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const registryModule = await import("@server/extractors/registry");
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from "@shared/location-support.js";
|
||||
import { normalizeStringArray } from "@shared/normalize-string-array.js";
|
||||
import {
|
||||
inferCountryKeyFromSearchGeography,
|
||||
matchesRequestedCity,
|
||||
resolveSearchCities,
|
||||
shouldApplyStrictCityFilter,
|
||||
@ -106,12 +107,67 @@ export async function discoverJobsStep(args: {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const profileSetting = settings.jobSearchProfile;
|
||||
if (profileSetting) {
|
||||
try {
|
||||
const profile = JSON.parse(profileSetting);
|
||||
if (
|
||||
Array.isArray(profile.targetRoles) &&
|
||||
profile.targetRoles.length > 0
|
||||
) {
|
||||
const existingLower = new Set(searchTerms.map((t) => t.toLowerCase()));
|
||||
for (const role of profile.targetRoles) {
|
||||
if (
|
||||
typeof role === "string" &&
|
||||
role.trim() &&
|
||||
!existingLower.has(role.trim().toLowerCase())
|
||||
) {
|
||||
searchTerms.push(role.trim());
|
||||
existingLower.add(role.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
logger.info("Augmented search terms with profile target roles", {
|
||||
addedRoles: profile.targetRoles.length,
|
||||
totalTerms: searchTerms.length,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// malformed profile JSON, continue with existing terms
|
||||
}
|
||||
}
|
||||
|
||||
const geographyCountryKey = inferCountryKeyFromSearchGeography(
|
||||
settings.searchCities,
|
||||
settings.jobspyLocation,
|
||||
);
|
||||
const configuredIndeedKey = settings.jobspyCountryIndeed?.trim()
|
||||
? normalizeCountryKey(settings.jobspyCountryIndeed)
|
||||
: null;
|
||||
if (
|
||||
geographyCountryKey &&
|
||||
configuredIndeedKey &&
|
||||
geographyCountryKey !== configuredIndeedKey
|
||||
) {
|
||||
logger.warn(
|
||||
"Indeed country setting disagrees with country-level search geography; aligning JobSpy and source routing to geography",
|
||||
{
|
||||
step: "discover-jobs",
|
||||
geographyCountryKey,
|
||||
jobspyCountryIndeed: configuredIndeedKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const selectedCountry = normalizeCountryKey(
|
||||
settings.jobspyCountryIndeed ??
|
||||
geographyCountryKey ??
|
||||
settings.jobspyCountryIndeed ??
|
||||
settings.searchCities ??
|
||||
settings.jobspyLocation ??
|
||||
"united kingdom",
|
||||
);
|
||||
|
||||
const effectiveJobspyCountryIndeed =
|
||||
geographyCountryKey ?? settings.jobspyCountryIndeed;
|
||||
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
||||
isSourceAllowedForCountry(source, selectedCountry),
|
||||
);
|
||||
@ -188,6 +244,10 @@ export async function discoverJobsStep(args: {
|
||||
),
|
||||
) as Record<string, string | undefined>;
|
||||
|
||||
if (effectiveJobspyCountryIndeed !== undefined) {
|
||||
filteredSettings.jobspyCountryIndeed = effectiveJobspyCountryIndeed;
|
||||
}
|
||||
|
||||
const result = await manifest.run({
|
||||
source: grouped.sources[0],
|
||||
selectedSources: grouped.sources,
|
||||
|
||||
@ -59,6 +59,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
||||
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||
score: 40,
|
||||
reason: "Low fit",
|
||||
analysis: null,
|
||||
});
|
||||
vi.mocked(visaSponsors.searchSponsors).mockResolvedValue([]);
|
||||
vi.mocked(visaSponsors.calculateSponsorMatchSummary).mockReturnValue({
|
||||
@ -103,6 +104,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
||||
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||
score: 50,
|
||||
reason: "At threshold",
|
||||
analysis: null,
|
||||
});
|
||||
|
||||
await scoreJobsStep({ profile: {} });
|
||||
@ -205,8 +207,16 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
||||
]);
|
||||
|
||||
vi.mocked(scorer.scoreJobSuitability)
|
||||
.mockResolvedValueOnce({ score: 61, reason: "First score" })
|
||||
.mockResolvedValueOnce({ score: 72, reason: "Second score" });
|
||||
.mockResolvedValueOnce({
|
||||
score: 61,
|
||||
reason: "First score",
|
||||
analysis: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
score: 72,
|
||||
reason: "Second score",
|
||||
analysis: null,
|
||||
});
|
||||
|
||||
const result = await scoreJobsStep({ profile: {} });
|
||||
|
||||
|
||||
@ -63,7 +63,10 @@ export async function scoreJobsStep(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const { score, reason } = await scoreJobSuitability(job, args.profile);
|
||||
const { score, reason, analysis } = await scoreJobSuitability(
|
||||
job,
|
||||
args.profile,
|
||||
);
|
||||
if (args.shouldCancel?.()) return;
|
||||
|
||||
let sponsorMatchScore = 0;
|
||||
@ -81,7 +84,6 @@ export async function scoreJobsStep(args: {
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
// Check if job should be auto-skipped based on score threshold
|
||||
const shouldAutoSkip =
|
||||
job.status !== "applied" &&
|
||||
autoSkipThreshold !== null &&
|
||||
@ -91,6 +93,7 @@ export async function scoreJobsStep(args: {
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||
|
||||
@ -17,7 +17,9 @@ import { db, schema } from "../db/index";
|
||||
|
||||
const { jobs } = schema;
|
||||
|
||||
function normalizeCreateJobInputForDedup(input: CreateJobInput): CreateJobInput {
|
||||
function normalizeCreateJobInputForDedup(
|
||||
input: CreateJobInput,
|
||||
): CreateJobInput {
|
||||
const jobUrl = canonicalizeJobUrl(input.jobUrl);
|
||||
if (jobUrl === input.jobUrl) return input;
|
||||
return { ...input, jobUrl };
|
||||
@ -45,8 +47,7 @@ async function loadJobDedupIndexes(): Promise<{
|
||||
const existingSourceJobKeySet = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
|
||||
(r) => r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
|
||||
)
|
||||
.map((r) => sourceJobKey(r.source, String(r.sourceJobId))),
|
||||
);
|
||||
@ -54,7 +55,10 @@ async function loadJobDedupIndexes(): Promise<{
|
||||
}
|
||||
|
||||
async function findJobByCanonicalUrl(canonical: string): Promise<Job | null> {
|
||||
const [exact] = await db.select().from(jobs).where(eq(jobs.jobUrl, canonical));
|
||||
const [exact] = await db
|
||||
.select()
|
||||
.from(jobs)
|
||||
.where(eq(jobs.jobUrl, canonical));
|
||||
if (exact) return mapRowToJob(exact);
|
||||
|
||||
const allRows = await db.select().from(jobs);
|
||||
@ -547,6 +551,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||
closedAt: row.closedAt ?? null,
|
||||
suitabilityScore: row.suitabilityScore,
|
||||
suitabilityReason: row.suitabilityReason,
|
||||
suitabilityAnalysis: row.suitabilityAnalysis ?? null,
|
||||
coverLetter: row.coverLetter ?? null,
|
||||
tailoredSummary: row.tailoredSummary,
|
||||
tailoredHeadline: row.tailoredHeadline ?? null,
|
||||
tailoredSkills: row.tailoredSkills ?? null,
|
||||
|
||||
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 { clearProfileCache, getProfile } from "./profile";
|
||||
|
||||
@ -19,23 +22,42 @@ vi.mock("./rxresume", () => ({
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
||||
|
||||
function mockRxResumeOnlyFlow(resumeId: string) {
|
||||
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
||||
if (key === "localResumeProfilePath") return null;
|
||||
if (key === "rxresumeMode") return "v5";
|
||||
if (
|
||||
key === "rxresumeBaseResumeId" ||
|
||||
key === "rxresumeBaseResumeIdV5" ||
|
||||
key === "rxresumeBaseResumeIdV4"
|
||||
) {
|
||||
return resumeId;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
describe("getProfile", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearProfileCache();
|
||||
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
|
||||
});
|
||||
|
||||
it("should throw an error if rxresumeBaseResumeId is not configured", async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue(null);
|
||||
it("should throw an error if no local file and rxresumeBaseResumeId is not configured", async () => {
|
||||
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
||||
if (key === "localResumeProfilePath") return null;
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(getProfile()).rejects.toThrow(
|
||||
"Base resume not configured. Please select a base resume from your RxResume account in Settings.",
|
||||
/Base resume not configured|JOBOPS_LOCAL_RESUME_PATH|local resume path/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch profile from Reactive Resume when configured", async () => {
|
||||
const mockResumeData = { basics: { name: "Test User" } };
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
mockRxResumeOnlyFlow("test-resume-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "test-resume-id",
|
||||
data: mockResumeData,
|
||||
@ -43,15 +65,13 @@ describe("getProfile", () => {
|
||||
|
||||
const profile = await getProfile();
|
||||
|
||||
expect(getSetting).toHaveBeenCalledWith("rxresumeMode");
|
||||
expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId");
|
||||
expect(getResume).toHaveBeenCalledWith("test-resume-id");
|
||||
expect(profile).toEqual(mockResumeData);
|
||||
});
|
||||
|
||||
it("should cache the profile and not refetch on subsequent calls", async () => {
|
||||
const mockResumeData = { basics: { name: "Test User" } };
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
mockRxResumeOnlyFlow("test-resume-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "test-resume-id",
|
||||
data: mockResumeData,
|
||||
@ -60,15 +80,12 @@ describe("getProfile", () => {
|
||||
await getProfile();
|
||||
await getProfile();
|
||||
|
||||
// The helper reads mode + legacy/per-mode resume-id settings each call.
|
||||
expect(getSetting).toHaveBeenCalledTimes(8);
|
||||
// But getResume should only be called once due to caching
|
||||
expect(getResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should refetch when forceRefresh is true", async () => {
|
||||
const mockResumeData = { basics: { name: "Test User" } };
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
mockRxResumeOnlyFlow("test-resume-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "test-resume-id",
|
||||
data: mockResumeData,
|
||||
@ -86,7 +103,7 @@ describe("getProfile", () => {
|
||||
});
|
||||
|
||||
it("should throw user-friendly error on credential issues", async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
mockRxResumeOnlyFlow("test-resume-id");
|
||||
vi.mocked(getResume).mockRejectedValue(
|
||||
new (RxResumeAuthConfigError as unknown as new () => Error)(),
|
||||
);
|
||||
@ -97,7 +114,7 @@ describe("getProfile", () => {
|
||||
});
|
||||
|
||||
it("should throw error if resume data is empty", async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
mockRxResumeOnlyFlow("test-resume-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "test-resume-id",
|
||||
data: null,
|
||||
@ -107,4 +124,41 @@ describe("getProfile", () => {
|
||||
"Resume data is empty or invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads profile from localResumeProfilePath when set", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-"));
|
||||
const filePath = join(dir, "resume.json");
|
||||
const mockResumeData = { basics: { name: "Local User" } };
|
||||
await writeFile(filePath, JSON.stringify(mockResumeData), "utf8");
|
||||
|
||||
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
||||
if (key === "localResumeProfilePath") return filePath;
|
||||
return null;
|
||||
});
|
||||
|
||||
const profile = await getProfile();
|
||||
expect(profile).toEqual(mockResumeData);
|
||||
expect(getResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers JOBOPS_LOCAL_RESUME_PATH over RxResume", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-env-"));
|
||||
const filePath = join(dir, "resume.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ basics: { name: "Env User" } }),
|
||||
"utf8",
|
||||
);
|
||||
process.env.JOBOPS_LOCAL_RESUME_PATH = filePath;
|
||||
|
||||
mockRxResumeOnlyFlow("ignored-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "ignored-id",
|
||||
data: { basics: { name: "Remote User" } },
|
||||
} as any);
|
||||
|
||||
const profile = await getProfile();
|
||||
expect(profile.basics?.name).toBe("Env User");
|
||||
expect(getResume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { logger } from "@infra/logger";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||
@ -6,22 +9,97 @@ import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||
let cachedProfile: ResumeProfile | null = null;
|
||||
let cachedResumeId: string | null = null;
|
||||
|
||||
/** Cache key is absolute path + file mtime (ms). */
|
||||
let cachedLocalSourceKey: string | null = null;
|
||||
let cachedLocalProfile: ResumeProfile | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from RxResume.
|
||||
* Prefer `JOBOPS_LOCAL_RESUME_PATH`, then `localResumeProfilePath` setting.
|
||||
* Relative paths resolve against `process.cwd()` (orchestrator working directory).
|
||||
*/
|
||||
export async function resolveLocalResumeFilePath(): Promise<string | null> {
|
||||
const envPath = process.env.JOBOPS_LOCAL_RESUME_PATH?.trim();
|
||||
const raw = envPath ?? (await getSetting("localResumeProfilePath"))?.trim();
|
||||
if (!raw) return null;
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
}
|
||||
|
||||
async function loadProfileFromLocalFile(
|
||||
absolutePath: string,
|
||||
forceRefresh: boolean,
|
||||
): Promise<ResumeProfile> {
|
||||
let mtimeKey = "unknown";
|
||||
try {
|
||||
const st = await stat(absolutePath);
|
||||
mtimeKey = String(st.mtimeMs);
|
||||
} catch {
|
||||
mtimeKey = "missing";
|
||||
}
|
||||
const cacheKey = `${absolutePath}:${mtimeKey}`;
|
||||
|
||||
if (
|
||||
!forceRefresh &&
|
||||
cacheKey === cachedLocalSourceKey &&
|
||||
cachedLocalProfile !== null
|
||||
) {
|
||||
return cachedLocalProfile;
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(absolutePath, "utf8");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Cannot read local resume file at ${absolutePath}. ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Local resume file at ${absolutePath} is not valid JSON (Reactive Resume export shape expected).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`Local resume file at ${absolutePath} must be a single JSON object.`,
|
||||
);
|
||||
}
|
||||
|
||||
cachedLocalSourceKey = cacheKey;
|
||||
cachedLocalProfile = parsed as ResumeProfile;
|
||||
logger.info("Profile loaded from local JSON file", {
|
||||
path: absolutePath,
|
||||
});
|
||||
return cachedLocalProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base resume profile: local JSON file (if configured) first, else Reactive Resume API.
|
||||
*
|
||||
* Local sources (in order): `JOBOPS_LOCAL_RESUME_PATH` env, then `localResumeProfilePath` setting.
|
||||
* Otherwise requires `rxresumeBaseResumeId` and RxResume credentials.
|
||||
*
|
||||
* Requires rxresumeBaseResumeId to be configured in settings.
|
||||
* Results are cached until clearProfileCache() is called.
|
||||
*
|
||||
* @param forceRefresh Force reload from API.
|
||||
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
|
||||
* @param forceRefresh Force reload from disk or API.
|
||||
*/
|
||||
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
||||
const localPath = await resolveLocalResumeFilePath();
|
||||
if (localPath) {
|
||||
return loadProfileFromLocalFile(localPath, forceRefresh);
|
||||
}
|
||||
|
||||
const { resumeId: rxresumeBaseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
throw new Error(
|
||||
"Base resume not configured. Please select a base resume from your RxResume account in Settings.",
|
||||
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or local resume path in Settings, or select a base resume from Reactive Resume.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -78,4 +156,6 @@ export async function getPersonName(): Promise<string> {
|
||||
export function clearProfileCache(): void {
|
||||
cachedProfile = null;
|
||||
cachedResumeId = null;
|
||||
cachedLocalSourceKey = null;
|
||||
cachedLocalProfile = null;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { Job } from "@shared/types";
|
||||
import type { Job, JobSearchProfile, SuitabilityAnalysis } from "@shared/types";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
||||
@ -11,15 +11,17 @@ import { resolveLlmModel } from "./modelSelection";
|
||||
import { getEffectiveSettings } from "./settings";
|
||||
|
||||
interface SuitabilityResult {
|
||||
score: number; // 0-100
|
||||
reason: string; // Explanation
|
||||
score: number;
|
||||
reason: string;
|
||||
analysis: SuitabilityAnalysis | null;
|
||||
}
|
||||
|
||||
type ScoringPreferences = {
|
||||
instructions: string;
|
||||
jobSearchProfile: JobSearchProfile | null;
|
||||
};
|
||||
|
||||
/** JSON schema for suitability scoring response */
|
||||
/** JSON schema for suitability scoring response (enhanced with analysis) */
|
||||
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
||||
name: "job_suitability_score",
|
||||
schema: {
|
||||
@ -33,12 +35,59 @@ const SCORING_SCHEMA: JsonSchemaDefinition = {
|
||||
type: "string",
|
||||
description: "Brief 1-2 sentence explanation of the score",
|
||||
},
|
||||
roleTypeMatch: {
|
||||
type: "integer",
|
||||
description:
|
||||
"How well the job role type matches what the candidate wants (0-100)",
|
||||
},
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
interface ScoringLlmResponse {
|
||||
score: number;
|
||||
reason: string;
|
||||
roleTypeMatch?: number;
|
||||
strengths?: string[];
|
||||
gaps?: string[];
|
||||
suggestions?: string[];
|
||||
dealBreakerHits?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a job's salary field is missing/empty.
|
||||
* Returns true for null, empty string, or whitespace-only strings.
|
||||
@ -80,6 +129,23 @@ function applySalaryPenalty(
|
||||
return { score: adjustedScore, reason: adjustedReason, penaltyApplied: true };
|
||||
}
|
||||
|
||||
function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null {
|
||||
if (!data.strengths && !data.gaps && !data.suggestions) return null;
|
||||
|
||||
return {
|
||||
roleTypeMatch:
|
||||
typeof data.roleTypeMatch === "number"
|
||||
? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch)))
|
||||
: 50,
|
||||
strengths: Array.isArray(data.strengths) ? data.strengths : [],
|
||||
gaps: Array.isArray(data.gaps) ? data.gaps : [],
|
||||
suggestions: Array.isArray(data.suggestions) ? data.suggestions : [],
|
||||
dealBreakerHits: Array.isArray(data.dealBreakerHits)
|
||||
? data.dealBreakerHits
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a job's suitability based on profile and job description.
|
||||
* Includes retry logic for when AI returns garbage responses.
|
||||
@ -93,12 +159,16 @@ export async function scoreJobSuitability(
|
||||
getEffectiveSettings(),
|
||||
]);
|
||||
|
||||
const jobSearchProfile = settings.jobSearchProfile?.value ?? null;
|
||||
const hasProfile = jobSearchProfile && hasNonEmptyProfile(jobSearchProfile);
|
||||
|
||||
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
|
||||
instructions: settings.scoringInstructions?.value ?? "",
|
||||
jobSearchProfile: hasProfile ? jobSearchProfile : null,
|
||||
});
|
||||
|
||||
const llm = new LlmService();
|
||||
const result = await llm.callJson<{ score: number; reason: string }>({
|
||||
const result = await llm.callJson<ScoringLlmResponse>({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
jsonSchema: SCORING_SCHEMA,
|
||||
@ -120,10 +190,9 @@ export async function scoreJobSuitability(
|
||||
});
|
||||
}
|
||||
|
||||
const { score, reason } = result.data;
|
||||
const data = result.data;
|
||||
|
||||
// Validate we got a reasonable response
|
||||
if (typeof score !== "number" || Number.isNaN(score)) {
|
||||
if (typeof data.score !== "number" || Number.isNaN(data.score)) {
|
||||
logger.error("Invalid score in AI response, using mock scoring", {
|
||||
jobId: job.id,
|
||||
});
|
||||
@ -133,10 +202,10 @@ export async function scoreJobSuitability(
|
||||
});
|
||||
}
|
||||
|
||||
const clampedScore = Math.min(100, Math.max(0, Math.round(score)));
|
||||
const clampedReason = reason || "No explanation provided";
|
||||
const clampedScore = Math.min(100, Math.max(0, Math.round(data.score)));
|
||||
const clampedReason = data.reason || "No explanation provided";
|
||||
const analysis = extractAnalysis(data);
|
||||
|
||||
// Apply salary penalty if enabled
|
||||
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
|
||||
penalizeMissingSalary: settings.penalizeMissingSalary.value,
|
||||
missingSalaryPenalty: settings.missingSalaryPenalty.value,
|
||||
@ -145,9 +214,20 @@ export async function scoreJobSuitability(
|
||||
return {
|
||||
score: penaltyResult.score,
|
||||
reason: penaltyResult.reason,
|
||||
analysis,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNonEmptyProfile(p: JobSearchProfile): boolean {
|
||||
return (
|
||||
p.targetRoles.length > 0 ||
|
||||
p.mustHaveSkills.length > 0 ||
|
||||
p.dealBreakers.length > 0 ||
|
||||
p.aboutMe.trim().length > 0 ||
|
||||
p.experienceLevel.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Robustly parse JSON from AI-generated content.
|
||||
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
||||
@ -161,44 +241,33 @@ export function parseJsonFromContent(
|
||||
const originalContent = content;
|
||||
let candidate = content.trim();
|
||||
|
||||
// Step 1: Remove markdown code fences (with or without language specifier)
|
||||
candidate = stripMarkdownCodeFences(candidate);
|
||||
|
||||
// Step 2: Try to extract JSON object if there's surrounding text
|
||||
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
candidate = jsonMatch[0];
|
||||
}
|
||||
|
||||
// Step 3: Try direct parse first
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
// Continue with sanitization
|
||||
}
|
||||
|
||||
// Step 4: Fix common JSON issues
|
||||
let sanitized = candidate;
|
||||
|
||||
// Remove JavaScript-style comments (// and /* */)
|
||||
sanitized = sanitized.replace(/\/\/[^\n]*/g, "");
|
||||
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Remove trailing commas before } or ]
|
||||
sanitized = sanitized.replace(/,\s*([\]}])/g, "$1");
|
||||
|
||||
// Fix unquoted keys: word: -> "word":
|
||||
// Be more careful - only match at start of object or after comma
|
||||
sanitized = sanitized.replace(
|
||||
/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g,
|
||||
'$1"$2":',
|
||||
);
|
||||
|
||||
// Fix single quotes to double quotes
|
||||
sanitized = sanitized.replace(/'/g, '"');
|
||||
|
||||
// Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON)
|
||||
// First, let's normalize the string - escape actual newlines inside strings
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed to fix broken JSON from AI
|
||||
const controlCharsRegex = /[\x00-\x1F\x7F]/g;
|
||||
sanitized = sanitized.replace(controlCharsRegex, (match) => {
|
||||
@ -208,15 +277,12 @@ export function parseJsonFromContent(
|
||||
return "";
|
||||
});
|
||||
|
||||
// Step 5: Try parsing the sanitized version
|
||||
try {
|
||||
return JSON.parse(sanitized);
|
||||
} catch {
|
||||
// Continue with more aggressive extraction
|
||||
}
|
||||
|
||||
// Step 6: Even more aggressive - try to rebuild a minimal valid JSON
|
||||
// by extracting just the score and reason values
|
||||
const scoreMatch = originalContent.match(
|
||||
/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i,
|
||||
);
|
||||
@ -238,7 +304,6 @@ export function parseJsonFromContent(
|
||||
return { score, reason };
|
||||
}
|
||||
|
||||
// Log the failure with full content for debugging
|
||||
logger.error("Failed to parse AI response", {
|
||||
jobId: jobId || "unknown",
|
||||
rawSample: originalContent.substring(0, 500),
|
||||
@ -253,16 +318,59 @@ function buildScoringPrompt(
|
||||
profile: Record<string, unknown>,
|
||||
preferences: ScoringPreferences,
|
||||
): string {
|
||||
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
|
||||
const p = preferences.jobSearchProfile;
|
||||
const hasProfilePrefs = p !== null;
|
||||
|
||||
SCORING CRITERIA:
|
||||
const profilePrefsBlock = hasProfilePrefs
|
||||
? `
|
||||
CANDIDATE JOB SEARCH PREFERENCES (CRITICAL - weigh these heavily):
|
||||
Target Roles: ${p.targetRoles.length > 0 ? p.targetRoles.join(", ") : "Not specified"}
|
||||
Experience Level: ${p.experienceLevel || "Not specified"}
|
||||
Must-Have Skills: ${p.mustHaveSkills.length > 0 ? p.mustHaveSkills.join(", ") : "Not specified"}
|
||||
Nice-to-Have Skills: ${p.niceToHaveSkills.length > 0 ? p.niceToHaveSkills.join(", ") : "Not specified"}
|
||||
Deal-Breakers (score 0-15 if triggered): ${p.dealBreakers.length > 0 ? p.dealBreakers.join(", ") : "None"}
|
||||
Preferred Work Arrangement: ${p.preferredWorkArrangement.length > 0 ? p.preferredWorkArrangement.join(", ") : "Any"}
|
||||
Preferred Locations: ${p.preferredLocations.length > 0 ? p.preferredLocations.join(", ") : "Any"}
|
||||
Minimum Salary: ${p.minimumSalary || "Not specified"}
|
||||
Target Industries: ${p.industriesToTarget.length > 0 ? p.industriesToTarget.join(", ") : "Any"}
|
||||
Industries to Avoid: ${p.industriesToAvoid.length > 0 ? p.industriesToAvoid.join(", ") : "None"}
|
||||
About the Candidate: ${p.aboutMe || "Not provided"}`
|
||||
: "";
|
||||
|
||||
const dealBreakerRules = hasProfilePrefs
|
||||
? `
|
||||
DEAL-BREAKER RULES (STRICTLY ENFORCE):
|
||||
- If the job's primary role type fundamentally mismatches the candidate's target roles, score MUST be 0-20.
|
||||
Example: If candidate wants "automation tester" roles, a "Full Stack Developer" job should score very low
|
||||
even if the description mentions testing tools. The JOB TITLE and PRIMARY RESPONSIBILITIES matter most.
|
||||
- If any deal-breaker keywords appear in the job title or core requirements, score MUST be 0-15.
|
||||
- If the job requires experience far beyond the candidate's level, reduce score by 30-50 points.
|
||||
- A job mentioning a candidate's skill as a minor "nice-to-have" does NOT make it a good match
|
||||
if the core role is completely different from what the candidate wants.`
|
||||
: "";
|
||||
|
||||
const scoringCriteria = hasProfilePrefs
|
||||
? `SCORING CRITERIA (with candidate preferences):
|
||||
- Role type alignment with target roles: 0-35 points (MOST IMPORTANT - is this the KIND of job they want?)
|
||||
- Skills match (must-haves weighted 3x, nice-to-haves 1x): 0-25 points
|
||||
- Experience level match: 0-15 points
|
||||
- Location/remote work alignment with preferences: 0-10 points
|
||||
- Industry/domain fit: 0-10 points
|
||||
- Career growth and salary alignment: 0-5 points`
|
||||
: `SCORING CRITERIA:
|
||||
- Skills match (technologies, frameworks, languages): 0-30 points
|
||||
- Experience level match: 0-25 points
|
||||
- Location/remote work alignment: 0-15 points
|
||||
- Industry/domain fit: 0-15 points
|
||||
- Career growth potential: 0-15 points
|
||||
- Career growth potential: 0-15 points`;
|
||||
|
||||
CANDIDATE PROFILE:
|
||||
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
|
||||
|
||||
${scoringCriteria}
|
||||
${dealBreakerRules}
|
||||
${profilePrefsBlock}
|
||||
|
||||
CANDIDATE RESUME:
|
||||
${JSON.stringify(profile, null, 2)}
|
||||
|
||||
JOB LISTING:
|
||||
@ -286,13 +394,20 @@ ${
|
||||
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
||||
|
||||
REQUIRED FORMAT (exactly this structure):
|
||||
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>"}
|
||||
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>", "roleTypeMatch": <integer 0-100>, "strengths": ["<strength 1>", "<strength 2>"], "gaps": ["<gap 1>"], "suggestions": ["<suggestion 1>"], "dealBreakerHits": []}
|
||||
|
||||
RULES FOR ANALYSIS FIELDS:
|
||||
- "roleTypeMatch": How well does this job's role TYPE match what the candidate wants? 100 = perfect role type, 0 = completely wrong type of work.
|
||||
- "strengths": 2-4 specific things where the candidate is a strong match. Be concrete (e.g. "Has 2 years React experience matching the 1+ year requirement").
|
||||
- "gaps": 1-3 specific skills/requirements the candidate lacks. Be honest and specific.
|
||||
- "suggestions": 1-3 actionable things the candidate could do to be stronger for this type of role.
|
||||
- "dealBreakerHits": List any deal-breakers triggered. Empty array if none.
|
||||
|
||||
EXAMPLE VALID RESPONSE:
|
||||
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
|
||||
{"score": 25, "reason": "This is a full-stack developer role but the candidate is targeting automation testing positions. The mention of Playwright in the description is minor and not the core focus.", "roleTypeMatch": 15, "strengths": ["Has Playwright experience mentioned in the job description", "Located in the same city"], "gaps": ["No React/Node.js full-stack experience", "Job requires 3+ years of backend development"], "suggestions": ["If interested in full-stack, build portfolio projects with React and Node.js", "Consider SDET roles that bridge testing and development"], "dealBreakerHits": ["Role type mismatch: Full Stack Developer vs target of Automation Tester"]}`;
|
||||
}
|
||||
|
||||
function sanitizeProfileForPrompt(
|
||||
export function sanitizeProfileForPrompt(
|
||||
profile: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const p = profile as {
|
||||
@ -328,7 +443,6 @@ async function mockScore(
|
||||
job: Job,
|
||||
settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
|
||||
): Promise<SuitabilityResult> {
|
||||
// Simple keyword-based scoring as fallback
|
||||
const jd = (job.jobDescription || "").toLowerCase();
|
||||
const title = job.title.toLowerCase();
|
||||
|
||||
@ -368,12 +482,12 @@ async function mockScore(
|
||||
|
||||
const baseReason = "Scored using keyword matching (API key not configured)";
|
||||
|
||||
// Apply salary penalty if enabled
|
||||
const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);
|
||||
|
||||
return {
|
||||
score: penaltyResult.score,
|
||||
reason: penaltyResult.reason,
|
||||
analysis: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -384,15 +498,25 @@ export async function scoreAndRankJobs(
|
||||
jobs: Job[],
|
||||
profile: Record<string, unknown>,
|
||||
): Promise<
|
||||
Array<Job & { suitabilityScore: number; suitabilityReason: string }>
|
||||
Array<
|
||||
Job & {
|
||||
suitabilityScore: number;
|
||||
suitabilityReason: string;
|
||||
suitabilityAnalysis: string | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const scoredJobs = await Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
const { score, reason } = await scoreJobSuitability(job, profile);
|
||||
const { score, reason, analysis } = await scoreJobSuitability(
|
||||
job,
|
||||
profile,
|
||||
);
|
||||
return {
|
||||
...job,
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -92,37 +92,40 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
});
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
if (rxresumeBaseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
if (resume.data && typeof resume.data === "object") {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
logger.warn(
|
||||
"Reactive Resume credentials missing during settings load",
|
||||
{
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
error,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
logger.warn("Failed to load Reactive Resume base resume for settings", {
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
error,
|
||||
});
|
||||
try {
|
||||
profile = (await getProfile()) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load base resume profile for settings (primary)", {
|
||||
error,
|
||||
});
|
||||
if (rxresumeBaseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
if (resume.data && typeof resume.data === "object") {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (rxError) {
|
||||
if (rxError instanceof RxResumeAuthConfigError) {
|
||||
logger.warn(
|
||||
"Reactive Resume credentials missing during settings load",
|
||||
{
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
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 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.
|
||||
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
|
||||
|
||||
result.localResumeFileConfigured =
|
||||
Boolean(process.env.JOBOPS_LOCAL_RESUME_PATH?.trim()) ||
|
||||
Boolean((overrides.localResumeProfilePath ?? "").trim());
|
||||
|
||||
return result as AppSettings;
|
||||
}
|
||||
|
||||
@ -63,6 +63,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// Extractors (e.g. startup.jobs / Apify-style KV) write under ./storage during
|
||||
// pipeline runs; watching those files causes spurious full page reloads.
|
||||
watch: {
|
||||
ignored: [path.resolve(__dirname, "storage")],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferCountryKeyFromSearchGeography,
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
resolveSearchCities,
|
||||
@ -64,6 +65,18 @@ describe("search-cities", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("infers country key from geography when a token is a supported country", () => {
|
||||
expect(inferCountryKeyFromSearchGeography("UK", null)).toBe(
|
||||
"united kingdom",
|
||||
);
|
||||
expect(inferCountryKeyFromSearchGeography("London|UK", null)).toBe(
|
||||
"united kingdom",
|
||||
);
|
||||
expect(inferCountryKeyFromSearchGeography(null, "Canada")).toBe("canada");
|
||||
expect(inferCountryKeyFromSearchGeography("Leeds", null)).toBeNull();
|
||||
expect(inferCountryKeyFromSearchGeography(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("applies strict filter only when city differs from country", () => {
|
||||
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
|
||||
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { normalizeCountryKey } from "./location-support.js";
|
||||
import {
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "./location-support.js";
|
||||
|
||||
const supportedCountryKeySet = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||
|
||||
const LOCATION_ALIASES: Record<string, string> = {
|
||||
uk: "united kingdom",
|
||||
@ -14,6 +19,23 @@ export function normalizeLocationToken(
|
||||
return LOCATION_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* If search geography includes a supported country token (e.g. "UK", "Canada"),
|
||||
* returns its normalized country key; otherwise null (e.g. "London" only).
|
||||
*/
|
||||
export function inferCountryKeyFromSearchGeography(
|
||||
searchCities?: string | null,
|
||||
jobspyLocation?: string | null,
|
||||
): string | null {
|
||||
const raw = searchCities?.trim() || jobspyLocation?.trim();
|
||||
if (!raw) return null;
|
||||
for (const token of parseSearchCitiesSetting(raw)) {
|
||||
const key = normalizeCountryKey(token);
|
||||
if (supportedCountryKeySet.has(key)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSearchCitiesSetting(
|
||||
value: string | null | undefined,
|
||||
): string[] {
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||
type ChatStyleLanguageMode,
|
||||
type ChatStyleManualLanguage,
|
||||
type JobSearchProfile,
|
||||
type ResumeProjectsSettings,
|
||||
} from "./types/settings";
|
||||
|
||||
@ -130,14 +131,58 @@ const parseChatStyleManualLanguageOrNull = createEnumParser(
|
||||
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
|
||||
const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES);
|
||||
|
||||
export const jobSearchProfileSchema = z.object({
|
||||
targetRoles: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
experienceLevel: z.string().trim().max(50),
|
||||
mustHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
niceToHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
dealBreakers: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
preferredWorkArrangement: z.array(z.string().trim().min(1).max(50)).max(5),
|
||||
preferredLocations: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
minimumSalary: z.string().trim().max(100),
|
||||
industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
industriesToAvoid: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
aboutMe: z.string().trim().max(4000),
|
||||
});
|
||||
|
||||
export const resumeProjectsSchema = z.object({
|
||||
maxProjects: z.number().int().min(0).max(100),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
});
|
||||
|
||||
const DEFAULT_JOB_SEARCH_PROFILE: JobSearchProfile = {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
};
|
||||
|
||||
export const settingsRegistry = {
|
||||
// --- Typed Settings ---
|
||||
jobSearchProfile: {
|
||||
kind: "typed" as const,
|
||||
schema: jobSearchProfileSchema,
|
||||
default: (): JobSearchProfile => DEFAULT_JOB_SEARCH_PROFILE,
|
||||
parse: (raw: string | undefined): JobSearchProfile | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
serialize: (value: JobSearchProfile | null | undefined): string | null => {
|
||||
return value ? JSON.stringify(value) : null;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
kind: "typed" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
@ -535,6 +580,10 @@ export const settingsRegistry = {
|
||||
},
|
||||
|
||||
// --- Simple Strings ---
|
||||
activeProfileId: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeBaseResumeId: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
@ -560,6 +609,11 @@ export const settingsRegistry = {
|
||||
z.string().trim().url().max(2000).nullable(),
|
||||
),
|
||||
},
|
||||
/** Server path to Reactive Resume JSON export; used when RxResume API is not available. */
|
||||
localResumeProfilePath: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(4000),
|
||||
},
|
||||
ukvisajobsEmail: {
|
||||
kind: "string" as const,
|
||||
envKey: "UKVISAJOBS_EMAIL",
|
||||
|
||||
@ -30,6 +30,8 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: "Strong fit",
|
||||
suitabilityAnalysis: null,
|
||||
coverLetter: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
@ -125,6 +127,35 @@ export const createResumeProjectCatalogItem = (
|
||||
export const createAppSettings = (
|
||||
overrides: Partial<AppSettings> = {},
|
||||
): AppSettings => ({
|
||||
jobSearchProfile: {
|
||||
value: {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
},
|
||||
default: {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
},
|
||||
override: null,
|
||||
},
|
||||
model: { value: "gpt-4o", default: "gpt-4o", override: null },
|
||||
modelScorer: { value: "gpt-4o", override: null },
|
||||
modelTailoring: { value: "gpt-4o", override: null },
|
||||
@ -147,6 +178,7 @@ export const createAppSettings = (
|
||||
},
|
||||
override: null,
|
||||
},
|
||||
activeProfileId: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
rxresumeBaseResumeIdV4: null,
|
||||
rxresumeBaseResumeIdV5: null,
|
||||
@ -213,6 +245,7 @@ export const createAppSettings = (
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumeUrl: null,
|
||||
localResumeProfilePath: null,
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPasswordHint: null,
|
||||
@ -222,6 +255,7 @@ export const createAppSettings = (
|
||||
adzunaAppKeyHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
localResumeFileConfigured: false,
|
||||
backupEnabled: { value: false, default: false, override: null },
|
||||
backupHour: { value: 3, default: 3, override: null },
|
||||
backupMaxCount: { value: 7, default: 7, override: null },
|
||||
|
||||
@ -148,12 +148,14 @@ export interface Job {
|
||||
closedAt: number | null;
|
||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||
suitabilityReason: string | null; // AI explanation
|
||||
suitabilityAnalysis: string | null; // JSON-encoded SuitabilityAnalysis
|
||||
tailoredSummary: string | null; // Generated resume summary
|
||||
tailoredHeadline: string | null; // Generated resume headline
|
||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
|
||||
coverLetter: string | null; // AI-generated cover letter
|
||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
@ -305,6 +307,7 @@ export interface UpdateJobInput {
|
||||
jobDescription?: string | null;
|
||||
suitabilityScore?: number;
|
||||
suitabilityReason?: string;
|
||||
suitabilityAnalysis?: string;
|
||||
tailoredSummary?: string;
|
||||
tailoredHeadline?: string;
|
||||
tailoredSkills?: string;
|
||||
@ -312,6 +315,7 @@ export interface UpdateJobInput {
|
||||
pdfPath?: string;
|
||||
tracerLinksEnabled?: boolean;
|
||||
appliedAt?: string;
|
||||
coverLetter?: string | null;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
}
|
||||
|
||||
@ -42,11 +42,15 @@ export interface JobsRevisionResponse {
|
||||
statusFilter: string | null;
|
||||
}
|
||||
|
||||
export type JobAction = "skip" | "move_to_ready" | "rescore";
|
||||
export type JobAction =
|
||||
| "skip"
|
||||
| "move_to_ready"
|
||||
| "rescore"
|
||||
| "generate_cover_letter";
|
||||
|
||||
export type JobActionRequest =
|
||||
| {
|
||||
action: "skip" | "rescore";
|
||||
action: "skip" | "rescore" | "generate_cover_letter";
|
||||
jobIds: string[];
|
||||
}
|
||||
| {
|
||||
|
||||
@ -1,3 +1,43 @@
|
||||
export interface JobSearchProfile {
|
||||
targetRoles: string[];
|
||||
experienceLevel: string;
|
||||
mustHaveSkills: string[];
|
||||
niceToHaveSkills: string[];
|
||||
dealBreakers: string[];
|
||||
preferredWorkArrangement: string[];
|
||||
preferredLocations: string[];
|
||||
minimumSalary: string;
|
||||
industriesToTarget: string[];
|
||||
industriesToAvoid: string[];
|
||||
aboutMe: string;
|
||||
}
|
||||
|
||||
export interface SearchProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
data: JobSearchProfile;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSearchProfileInput {
|
||||
name: string;
|
||||
data: JobSearchProfile;
|
||||
}
|
||||
|
||||
export interface UpdateSearchProfileInput {
|
||||
name?: string;
|
||||
data?: JobSearchProfile;
|
||||
}
|
||||
|
||||
export interface SuitabilityAnalysis {
|
||||
roleTypeMatch: number;
|
||||
strengths: string[];
|
||||
gaps: string[];
|
||||
suggestions: string[];
|
||||
dealBreakerHits: string[];
|
||||
}
|
||||
|
||||
export interface ResumeProjectCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -144,6 +184,7 @@ export type ModelResolved = { value: string; override: string | null };
|
||||
|
||||
export interface AppSettings {
|
||||
// Typed settings (Resolved):
|
||||
jobSearchProfile: Resolved<JobSearchProfile>;
|
||||
model: Resolved<string>;
|
||||
llmProvider: Resolved<string>;
|
||||
llmBaseUrl: Resolved<string>;
|
||||
@ -183,11 +224,14 @@ export interface AppSettings {
|
||||
modelProjectSelection: ModelResolved;
|
||||
|
||||
// Simple strings:
|
||||
activeProfileId: string | null;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
rxresumeBaseResumeIdV4: string | null;
|
||||
rxresumeBaseResumeIdV5: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumeUrl: string | null;
|
||||
/** Path to local Reactive Resume JSON (see JOBOPS_LOCAL_RESUME_PATH). */
|
||||
localResumeProfilePath: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
adzunaAppId: string | null;
|
||||
basicAuthUser: string | null;
|
||||
@ -203,5 +247,7 @@ export interface AppSettings {
|
||||
|
||||
// Computed:
|
||||
basicAuthActive: boolean;
|
||||
/** True when JOBOPS_LOCAL_RESUME_PATH is set on the server (not shown in UI). */
|
||||
localResumeFileConfigured: boolean;
|
||||
profileProjects: ResumeProjectCatalogItem[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user