[extractor] Manually import job description
Merge pull request #7 from DaKheera47/manually-import-job-description
This commit is contained in:
commit
65551b147f
@ -5,3 +5,4 @@ Technical breakdowns of how each extractor works.
|
||||
- Gradcracker: `gradcracker.md`
|
||||
- JobSpy: `jobspy.md`
|
||||
- UKVisaJobs: `ukvisajobs.md`
|
||||
- Manual Import: `manual.md`
|
||||
|
||||
38
documentation/extractors/manual.md
Normal file
38
documentation/extractors/manual.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Manual Import Extractor (How It Works)
|
||||
|
||||
This is a walkthrough of the manual job import flow, which allows users to add jobs that aren't captured by automated scrapers.
|
||||
|
||||
## Big Picture
|
||||
|
||||
Instead of scraping a website, the manual extractor takes a raw job description (pasted text), parses the details (using AI), and allows the user to review and edit the data before importing it into the pipeline.
|
||||
|
||||
## 1) Input
|
||||
|
||||
The user provides input via the **Manual Import** sheet in the UI. They paste a full job description, copied from any source (job board, company site, email, etc.).
|
||||
|
||||
## 2) AI Inference
|
||||
|
||||
When the user clicks "Analyze JD", the orchestrator calls an internal endpoint (`/api/manual-jobs/infer`).
|
||||
|
||||
The server-side service (`orchestrator/src/server/services/manualJob.ts`) then:
|
||||
- Sends the raw text to an LLM (via OpenRouter).
|
||||
- Uses a specific prompt to extract structured data (title, employer, location, salary, etc.).
|
||||
- Returns a JSON object containing the inferred fields.
|
||||
|
||||
If `OPENROUTER_API_KEY` is not configured, the inference step skips and warns the user to fill details manually.
|
||||
|
||||
## 3) Review and Edit
|
||||
|
||||
The inferred data is populated into a form in the UI. The user can:
|
||||
- Correct any mistakes made by the AI.
|
||||
- Add missing information.
|
||||
|
||||
## 4) Storage and Scoring
|
||||
|
||||
Once the user clicks "Import Job", the data is sent to `/api/manual-jobs/import`.
|
||||
|
||||
The orchestrator:
|
||||
- Generates a unique ID for the job if no URL is provided.
|
||||
- Saves the job to the database with the source set to `manual`.
|
||||
- **Asynchronously triggers scoring**: The job is immediately run through the suitability scorer (`orchestrator/src/server/services/scorer.ts`) against the user's current resume profile.
|
||||
- Updates the job record with the suitability score and reason once complete.
|
||||
@ -15,6 +15,8 @@ import type {
|
||||
UkVisaJobsSearchResponse,
|
||||
UkVisaJobsImportResponse,
|
||||
CreateJobInput,
|
||||
ManualJobDraft,
|
||||
ManualJobInferenceResponse,
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
@ -138,6 +140,25 @@ export async function importUkVisaJobs(input: {
|
||||
});
|
||||
}
|
||||
|
||||
// Manual Job Import API
|
||||
export async function inferManualJob(input: {
|
||||
jobDescription: string;
|
||||
}): Promise<ManualJobInferenceResponse> {
|
||||
return fetchApi<ManualJobInferenceResponse>('/manual-jobs/infer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importManualJob(input: {
|
||||
job: ManualJobDraft;
|
||||
}): Promise<Job> {
|
||||
return fetchApi<Job>('/manual-jobs/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
// Settings & Profile API
|
||||
export async function getSettings(): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings');
|
||||
|
||||
@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "../lib/dateUtils";
|
||||
import * as api from "../api";
|
||||
import { FitAssessment } from ".";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
@ -49,19 +50,6 @@ interface DiscoveredPanelProps {
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const stripHtml = (value: string) =>
|
||||
value
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
@ -73,6 +61,7 @@ const sourceLabel: Record<Job["source"], string> = {
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -60,6 +60,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
168
orchestrator/src/client/components/ManualImportSheet.test.tsx
Normal file
168
orchestrator/src/client/components/ManualImportSheet.test.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ManualImportSheet } from "./ManualImportSheet";
|
||||
import * as api from "../api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
inferManualJob: vi.fn(),
|
||||
importManualJob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ManualImportSheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs analyze -> review -> import on the happy path", async () => {
|
||||
const rawDescription = " Backend Engineer role in London. ";
|
||||
const onOpenChange = vi.fn();
|
||||
const onImported = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(api.inferManualJob).mockResolvedValue({
|
||||
job: {
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
location: "London, UK",
|
||||
},
|
||||
});
|
||||
vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any);
|
||||
|
||||
render(
|
||||
<ManualImportSheet open onOpenChange={onOpenChange} onImported={onImported} />
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Paste the full job description here..."),
|
||||
{ target: { value: rawDescription } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||
|
||||
const titleInput = await screen.findByPlaceholderText("e.g. Junior Backend Engineer");
|
||||
expect(titleInput).toHaveValue("Backend Engineer");
|
||||
|
||||
const jdTextarea = screen.getByPlaceholderText("Paste the job description...") as HTMLTextAreaElement;
|
||||
expect(jdTextarea.value).toBe(rawDescription.trim());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), {
|
||||
target: { value: " 120k " },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /import job/i }));
|
||||
|
||||
await waitFor(() => expect(api.importManualJob).toHaveBeenCalled());
|
||||
expect(api.importManualJob).toHaveBeenCalledWith({
|
||||
job: expect.objectContaining({
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
location: "London, UK",
|
||||
salary: "120k",
|
||||
jobDescription: rawDescription.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onImported).toHaveBeenCalledWith("job-1"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"Job imported",
|
||||
expect.objectContaining({
|
||||
description: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows warnings and requires required fields before import", async () => {
|
||||
const rawDescription = "Manual QA Engineer role.";
|
||||
|
||||
vi.mocked(api.inferManualJob).mockResolvedValue({
|
||||
job: {},
|
||||
warning: "AI inference failed. Fill details manually.",
|
||||
});
|
||||
|
||||
render(
|
||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Paste the full job description here..."),
|
||||
{ target: { value: rawDescription } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||
|
||||
await screen.findByText("AI inference failed. Fill details manually.");
|
||||
|
||||
const importButton = screen.getByRole("button", { name: /import job/i });
|
||||
expect(importButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. Junior Backend Engineer"), {
|
||||
target: { value: "QA Engineer" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), {
|
||||
target: { value: "Acme Labs" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(importButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it("returns to the paste step when inference fails", async () => {
|
||||
const rawDescription = "Backend role description.";
|
||||
|
||||
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
|
||||
|
||||
render(
|
||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Paste the full job description here..."),
|
||||
{ target: { value: rawDescription } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||
|
||||
await screen.findByText("Inference failed");
|
||||
expect(screen.getByRole("button", { name: /analyze jd/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText("e.g. Junior Backend Engineer")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a toast error and keeps the sheet open when import fails", async () => {
|
||||
vi.mocked(api.inferManualJob).mockResolvedValue({
|
||||
job: {
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
},
|
||||
});
|
||||
vi.mocked(api.importManualJob).mockRejectedValue(new Error("Import failed"));
|
||||
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ManualImportSheet open onOpenChange={onOpenChange} onImported={vi.fn()} />
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Paste the full job description here..."),
|
||||
{ target: { value: "Backend Engineer role." } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||
|
||||
await screen.findByPlaceholderText("e.g. Junior Backend Engineer");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /import job/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Import failed")
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
424
orchestrator/src/client/components/ManualImportSheet.tsx
Normal file
424
orchestrator/src/client/components/ManualImportSheet.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Manual job import flow (paste JD -> infer -> review -> import).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import type { ManualJobDraft } from "../../shared/types";
|
||||
|
||||
type ManualImportStep = "paste" | "loading" | "review";
|
||||
|
||||
type ManualJobDraftState = {
|
||||
title: string;
|
||||
employer: string;
|
||||
jobUrl: string;
|
||||
applicationLink: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobDescription: string;
|
||||
jobType: string;
|
||||
jobLevel: string;
|
||||
jobFunction: string;
|
||||
disciplines: string;
|
||||
degreeRequired: string;
|
||||
starting: string;
|
||||
};
|
||||
|
||||
const emptyDraft: ManualJobDraftState = {
|
||||
title: "",
|
||||
employer: "",
|
||||
jobUrl: "",
|
||||
applicationLink: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
deadline: "",
|
||||
jobDescription: "",
|
||||
jobType: "",
|
||||
jobLevel: "",
|
||||
jobFunction: "",
|
||||
disciplines: "",
|
||||
degreeRequired: "",
|
||||
starting: "",
|
||||
};
|
||||
|
||||
const normalizeDraft = (draft?: ManualJobDraft | null, jd?: string): ManualJobDraftState => ({
|
||||
...emptyDraft,
|
||||
title: draft?.title ?? "",
|
||||
employer: draft?.employer ?? "",
|
||||
jobUrl: draft?.jobUrl ?? "",
|
||||
applicationLink: draft?.applicationLink ?? "",
|
||||
location: draft?.location ?? "",
|
||||
salary: draft?.salary ?? "",
|
||||
deadline: draft?.deadline ?? "",
|
||||
jobDescription: jd ?? draft?.jobDescription ?? "",
|
||||
jobType: draft?.jobType ?? "",
|
||||
jobLevel: draft?.jobLevel ?? "",
|
||||
jobFunction: draft?.jobFunction ?? "",
|
||||
disciplines: draft?.disciplines ?? "",
|
||||
degreeRequired: draft?.degreeRequired ?? "",
|
||||
starting: draft?.starting ?? "",
|
||||
});
|
||||
|
||||
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
|
||||
const clean = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: clean(draft.title),
|
||||
employer: clean(draft.employer),
|
||||
jobUrl: clean(draft.jobUrl),
|
||||
applicationLink: clean(draft.applicationLink),
|
||||
location: clean(draft.location),
|
||||
salary: clean(draft.salary),
|
||||
deadline: clean(draft.deadline),
|
||||
jobDescription: clean(draft.jobDescription),
|
||||
jobType: clean(draft.jobType),
|
||||
jobLevel: clean(draft.jobLevel),
|
||||
jobFunction: clean(draft.jobFunction),
|
||||
disciplines: clean(draft.disciplines),
|
||||
degreeRequired: clean(draft.degreeRequired),
|
||||
starting: clean(draft.starting),
|
||||
};
|
||||
};
|
||||
|
||||
interface ManualImportSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported: (jobId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ManualImportStep>("paste");
|
||||
const [rawDescription, setRawDescription] = useState("");
|
||||
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStep("paste");
|
||||
setRawDescription("");
|
||||
setDraft(emptyDraft);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||
|
||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||
const canImport = useMemo(() => {
|
||||
if (step !== "review") return false;
|
||||
return (
|
||||
draft.title.trim().length > 0 &&
|
||||
draft.employer.trim().length > 0 &&
|
||||
draft.jobDescription.trim().length > 0
|
||||
);
|
||||
}, [draft, step]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!rawDescription.trim()) {
|
||||
setError("Paste a job description to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setStep("loading");
|
||||
const response = await api.inferManualJob({ jobDescription: rawDescription });
|
||||
setDraft(normalizeDraft(response.job, rawDescription.trim()));
|
||||
setWarning(response.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to analyze job description";
|
||||
setError(message);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!canImport) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
const payload = toPayload(draft);
|
||||
const created = await api.importManualJob({ job: payload });
|
||||
toast.success("Job imported", {
|
||||
description: "The job is now in the discovered column.",
|
||||
});
|
||||
await onImported(created.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to import job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-xl overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
Manual Import
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Paste a job description, review the AI draft, then import the role.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Step {stepIndex + 1} of 3</span>
|
||||
<span>{stepLabel}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted/40">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/60 transition-all"
|
||||
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1 overflow-y-auto pr-1">
|
||||
{step === "paste" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</label>
|
||||
<Textarea
|
||||
value={rawDescription}
|
||||
onChange={(event) => setRawDescription(event.target.value)}
|
||||
placeholder="Paste the full job description here..."
|
||||
className="min-h-[220px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!canAnalyze}
|
||||
className="w-full h-10 gap-2"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Analyze JD
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "loading" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-semibold">Analyzing job description</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Extracting title, company, location, and other details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4 pb-4">
|
||||
{warning && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep("paste")}
|
||||
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Edit JD
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Required: title, employer, description
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
||||
placeholder="e.g. Junior Backend Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Employer *</label>
|
||||
<Input
|
||||
value={draft.employer}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, employer: event.target.value }))}
|
||||
placeholder="e.g. Acme Labs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Location</label>
|
||||
<Input
|
||||
value={draft.location}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, location: event.target.value }))}
|
||||
placeholder="e.g. London, UK"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Salary</label>
|
||||
<Input
|
||||
value={draft.salary}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, salary: event.target.value }))}
|
||||
placeholder="e.g. GBP 45k-55k"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Deadline</label>
|
||||
<Input
|
||||
value={draft.deadline}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, deadline: event.target.value }))}
|
||||
placeholder="e.g. 30 Sep 2025"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job type</label>
|
||||
<Input
|
||||
value={draft.jobType}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobType: event.target.value }))}
|
||||
placeholder="e.g. Full-time"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job level</label>
|
||||
<Input
|
||||
value={draft.jobLevel}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobLevel: event.target.value }))}
|
||||
placeholder="e.g. Graduate"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job function</label>
|
||||
<Input
|
||||
value={draft.jobFunction}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobFunction: event.target.value }))}
|
||||
placeholder="e.g. Software Engineering"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Disciplines</label>
|
||||
<Input
|
||||
value={draft.disciplines}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, disciplines: event.target.value }))}
|
||||
placeholder="e.g. Computer Science"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Degree required</label>
|
||||
<Input
|
||||
value={draft.degreeRequired}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, degreeRequired: event.target.value }))}
|
||||
placeholder="e.g. BSc or MSc"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Starting</label>
|
||||
<Input
|
||||
value={draft.starting}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, starting: event.target.value }))}
|
||||
placeholder="e.g. Summer 2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job URL</label>
|
||||
<Input
|
||||
value={draft.jobUrl}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobUrl: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Application link</label>
|
||||
<Input
|
||||
value={draft.applicationLink}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, applicationLink: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Job description *
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.jobDescription}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobDescription: event.target.value }))}
|
||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
placeholder="Paste the job description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Separator />
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!canImport || isImporting}
|
||||
className={cn("w-full h-10 gap-2", !canImport && "opacity-70")}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
{isImporting ? "Importing..." : "Import job"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -7,4 +7,5 @@ export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
export { DiscoveredPanel } from './DiscoveredPanel';
|
||||
export { ReadyPanel } from './ReadyPanel';
|
||||
export { ManualImportSheet } from './ManualImportSheet';
|
||||
export * from './layout';
|
||||
|
||||
36
orchestrator/src/client/lib/dateUtils.ts
Normal file
36
orchestrator/src/client/lib/dateUtils.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
return parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
@ -60,7 +60,8 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import { PipelineProgress, DiscoveredPanel } from "../components";
|
||||
import { formatDate, formatDateTime } from "../lib/dateUtils";
|
||||
import { PipelineProgress, DiscoveredPanel, ManualImportSheet } from "../components";
|
||||
import { ReadyPanel } from "../components/ReadyPanel";
|
||||
import * as api from "../api";
|
||||
import { TailoringEditor } from "../components/TailoringEditor";
|
||||
@ -74,6 +75,7 @@ const sourceLabel: Record<JobSource, string> = {
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
@ -154,40 +156,6 @@ const emptyStateCopy: Record<FilterTab, string> = {
|
||||
all: "No jobs in the system yet. Run the pipeline to get started.",
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
|
||||
|
||||
const dateValue = (value: string | null) => {
|
||||
@ -324,6 +292,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
];
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
|
||||
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@ -384,6 +353,16 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleManualImported = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActiveTab("discovered");
|
||||
setSourceFilter("all");
|
||||
await loadJobs();
|
||||
setSelectedJobId(jobId);
|
||||
},
|
||||
[loadJobs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
@ -1123,6 +1102,15 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsManualImportOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Manual import</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -1408,6 +1396,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ManualImportSheet
|
||||
open={isManualImportOpen}
|
||||
onOpenChange={setIsManualImportOpen}
|
||||
onImported={handleManualImported}
|
||||
/>
|
||||
|
||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
|
||||
@ -38,43 +38,10 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatDateTime } from "../lib/dateUtils";
|
||||
import * as api from "../api";
|
||||
import type { CreateJobInput } from "../../shared/types";
|
||||
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import * as jobsRepo from '../repositories/jobs.js';
|
||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||
@ -10,6 +11,8 @@ import * as settingsRepo from '../repositories/settings.js';
|
||||
import { runPipeline, processJob, summarizeJob, generateFinalPdf, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||
import { createNotionEntry } from '../services/notion.js';
|
||||
import { fetchUkVisaJobsPage } from '../services/ukvisajobs.js';
|
||||
import { inferManualJobDetails } from '../services/manualJob.js';
|
||||
import { scoreJobSuitability } from '../services/scorer.js';
|
||||
import { clearDatabase } from '../db/clear.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
@ -18,7 +21,18 @@ import {
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../services/resumeProjects.js';
|
||||
import * as visaSponsors from '../services/visa-sponsors/index.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse, VisaSponsorSearchResponse, VisaSponsorStatusResponse } from '../../shared/types.js';
|
||||
import type {
|
||||
Job,
|
||||
JobStatus,
|
||||
ApiResponse,
|
||||
JobsListResponse,
|
||||
PipelineStatusResponse,
|
||||
UkVisaJobsSearchResponse,
|
||||
UkVisaJobsImportResponse,
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
ManualJobInferenceResponse,
|
||||
} from '../../shared/types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
let isUkVisaJobsSearchRunning = false;
|
||||
@ -456,7 +470,13 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
||||
if (resumeProjects === null) {
|
||||
await settingsRepo.setSetting('resumeProjects', null);
|
||||
} else {
|
||||
const profile = await loadResumeProfile();
|
||||
const rawProfile = await loadResumeProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
@ -735,6 +755,126 @@ apiRouter.post('/pipeline/run', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Manual Job Import API
|
||||
// ============================================================================
|
||||
|
||||
const manualJobInferenceSchema = z.object({
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
});
|
||||
|
||||
const manualJobImportSchema = z.object({
|
||||
job: z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
employer: z.string().trim().min(1).max(500),
|
||||
jobUrl: z.string().trim().url().max(2000).optional(),
|
||||
applicationLink: z.string().trim().url().max(2000).optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
salary: z.string().trim().max(200).optional(),
|
||||
deadline: z.string().trim().max(100).optional(),
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
jobType: z.string().trim().max(200).optional(),
|
||||
jobLevel: z.string().trim().max(200).optional(),
|
||||
jobFunction: z.string().trim().max(200).optional(),
|
||||
disciplines: z.string().trim().max(200).optional(),
|
||||
degreeRequired: z.string().trim().max(200).optional(),
|
||||
starting: z.string().trim().max(200).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const cleanOptional = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/infer - Infer job details from a pasted description
|
||||
*/
|
||||
apiRouter.post('/manual-jobs/infer', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobInferenceSchema.parse(req.body ?? {});
|
||||
const result = await inferManualJobDetails(input.jobDescription);
|
||||
|
||||
const response: ApiResponse<ManualJobInferenceResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
job: result.job,
|
||||
warning: result.warning ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/import - Import a manually curated job into the DB
|
||||
*/
|
||||
apiRouter.post('/manual-jobs/import', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobImportSchema.parse(req.body ?? {});
|
||||
const job = input.job;
|
||||
|
||||
const jobUrl =
|
||||
cleanOptional(job.jobUrl) ||
|
||||
cleanOptional(job.applicationLink) ||
|
||||
`manual://${randomUUID()}`;
|
||||
|
||||
const createdJob = await jobsRepo.createJob({
|
||||
source: 'manual',
|
||||
title: job.title.trim(),
|
||||
employer: job.employer.trim(),
|
||||
jobUrl,
|
||||
applicationLink: cleanOptional(job.applicationLink) ?? undefined,
|
||||
location: cleanOptional(job.location) ?? undefined,
|
||||
salary: cleanOptional(job.salary) ?? undefined,
|
||||
deadline: cleanOptional(job.deadline) ?? undefined,
|
||||
jobDescription: job.jobDescription.trim(),
|
||||
jobType: cleanOptional(job.jobType) ?? undefined,
|
||||
jobLevel: cleanOptional(job.jobLevel) ?? undefined,
|
||||
jobFunction: cleanOptional(job.jobFunction) ?? undefined,
|
||||
disciplines: cleanOptional(job.disciplines) ?? undefined,
|
||||
degreeRequired: cleanOptional(job.degreeRequired) ?? undefined,
|
||||
starting: cleanOptional(job.starting) ?? undefined,
|
||||
});
|
||||
|
||||
// Score asynchronously so the import returns immediately.
|
||||
(async () => {
|
||||
try {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format');
|
||||
}
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { score, reason } = await scoreJobSuitability(createdJob, profile);
|
||||
await jobsRepo.updateJob(createdJob.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Manual job scoring failed:', error);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.warn('Manual job scoring task failed to start:', error);
|
||||
});
|
||||
|
||||
res.json({ success: true, data: createdJob });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UK Visa Jobs API
|
||||
// ============================================================================
|
||||
@ -1054,4 +1194,3 @@ apiRouter.post('/visa-sponsors/update', async (req: Request, res: Response) => {
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export const jobs = sqliteTable('jobs', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
// From crawler
|
||||
source: text('source', { enum: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'] }).notNull().default('gradcracker'),
|
||||
source: text('source', { enum: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs', 'manual'] }).notNull().default('gradcracker'),
|
||||
sourceJobId: text('source_job_id'),
|
||||
jobUrlDirect: text('job_url_direct'),
|
||||
datePosted: text('date_posted'),
|
||||
|
||||
@ -8,16 +8,65 @@ import type { Job } from '../../shared/types.js';
|
||||
// We need to mock 'fetch' globally for these tests
|
||||
const globalFetch = global.fetch;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// A simple mock job
|
||||
const mockJob: Job = {
|
||||
id: 'test-job',
|
||||
employer: 'Test Corp',
|
||||
source: 'gradcracker',
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: 'Senior Engineer',
|
||||
employer: 'Test Corp',
|
||||
employerUrl: null,
|
||||
jobUrl: 'http://test.com',
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: 'Looking for a TypeScript and React expert.',
|
||||
url: 'http://test.com',
|
||||
date: '2023-01-01',
|
||||
source: 'test' as any,
|
||||
status: 'discovered'
|
||||
status: 'discovered',
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: now,
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const mockProfile = { name: 'Test User' };
|
||||
|
||||
74
orchestrator/src/server/services/manualJob.test.ts
Normal file
74
orchestrator/src/server/services/manualJob.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as settingsRepo from "../repositories/settings.js";
|
||||
import { inferManualJobDetails } from "./manualJob.js";
|
||||
|
||||
vi.mock("../repositories/settings.js", () => ({
|
||||
getSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
const originalEnv = process.env;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("manual job inference", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv, OPENROUTER_API_KEY: "test-key" };
|
||||
global.fetch = vi.fn();
|
||||
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a warning when the API key is missing", async () => {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
const result = await inferManualJobDetails("JD text");
|
||||
|
||||
expect(result.job).toEqual({});
|
||||
expect(result.warning).toContain("OPENROUTER_API_KEY not set");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses JSON even when wrapped in markdown fences", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content:
|
||||
"Here is the data: ```json\n{ \"title\": \"Backend Engineer\", \"employer\": \"Acme\", \"salary\": \" 100k \" }\n```",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await inferManualJobDetails("JD text");
|
||||
|
||||
expect(result.warning).toBeUndefined();
|
||||
expect(result.job).toMatchObject({
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
salary: "100k",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a warning when the API response fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as any);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await inferManualJobDetails("JD text");
|
||||
|
||||
expect(result.job).toEqual({});
|
||||
expect(result.warning).toContain("AI inference failed");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
163
orchestrator/src/server/services/manualJob.ts
Normal file
163
orchestrator/src/server/services/manualJob.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Service for inferring job details from a pasted job description.
|
||||
*/
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import type { ManualJobDraft } from '../../shared/types.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface ManualJobInferenceResult {
|
||||
job: ManualJobDraft;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
job: {},
|
||||
warning: 'OPENROUTER_API_KEY not set. Fill details manually.',
|
||||
};
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const prompt = buildInferencePrompt(jobDescription);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
const parsed = parseJsonFromContent(content);
|
||||
return { job: normalizeDraft(parsed) };
|
||||
} catch (error) {
|
||||
console.warn('Manual job inference failed:', error);
|
||||
return {
|
||||
job: {},
|
||||
warning: 'AI inference failed. Fill details manually.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildInferencePrompt(jd: string): string {
|
||||
return `
|
||||
You are extracting structured data from a job description.
|
||||
Return JSON only with the keys listed below. Use empty string if unknown.
|
||||
Do not guess or invent data.
|
||||
|
||||
Keys:
|
||||
- title
|
||||
- employer
|
||||
- location
|
||||
- salary
|
||||
- deadline
|
||||
- jobUrl (the listing URL, if present)
|
||||
- applicationLink (the apply URL, if present)
|
||||
- jobType
|
||||
- jobLevel
|
||||
- jobFunction
|
||||
- disciplines
|
||||
- degreeRequired
|
||||
- starting
|
||||
|
||||
JOB DESCRIPTION:
|
||||
${jd.slice(0, 8000)}${jd.length > 8000 ? '... (truncated)' : ''}
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
{
|
||||
"title": "",
|
||||
"employer": "",
|
||||
"location": "",
|
||||
"salary": "",
|
||||
"deadline": "",
|
||||
"jobUrl": "",
|
||||
"applicationLink": "",
|
||||
"jobType": "",
|
||||
"jobLevel": "",
|
||||
"jobFunction": "",
|
||||
"disciplines": "",
|
||||
"degreeRequired": "",
|
||||
"starting": ""
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function parseJsonFromContent(content: string): Record<string, unknown> {
|
||||
const trimmed = content.trim();
|
||||
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFences);
|
||||
} catch {
|
||||
const firstBrace = withoutFences.indexOf('{');
|
||||
const lastBrace = withoutFences.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sliced = withoutFences.slice(firstBrace, lastBrace + 1);
|
||||
return JSON.parse(sliced);
|
||||
}
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
|
||||
const fields: Array<keyof ManualJobDraft> = [
|
||||
'title',
|
||||
'employer',
|
||||
'location',
|
||||
'salary',
|
||||
'deadline',
|
||||
'jobUrl',
|
||||
'applicationLink',
|
||||
'jobType',
|
||||
'jobLevel',
|
||||
'jobFunction',
|
||||
'disciplines',
|
||||
'degreeRequired',
|
||||
'starting',
|
||||
];
|
||||
|
||||
const out: ManualJobDraft = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = toCleanString(parsed[field]);
|
||||
if (value) out[field] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCleanString(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@ -14,7 +14,8 @@ export type JobSource =
|
||||
| 'gradcracker'
|
||||
| 'indeed'
|
||||
| 'linkedin'
|
||||
| 'ukvisajobs';
|
||||
| 'ukvisajobs'
|
||||
| 'manual';
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@ -129,6 +130,28 @@ export interface CreateJobInput {
|
||||
workFromHomeType?: string;
|
||||
}
|
||||
|
||||
export interface ManualJobDraft {
|
||||
title?: string;
|
||||
employer?: string;
|
||||
jobUrl?: string;
|
||||
applicationLink?: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
deadline?: string;
|
||||
jobDescription?: string;
|
||||
jobType?: string;
|
||||
jobLevel?: string;
|
||||
jobFunction?: string;
|
||||
disciplines?: string;
|
||||
degreeRequired?: string;
|
||||
starting?: string;
|
||||
}
|
||||
|
||||
export interface ManualJobInferenceResponse {
|
||||
job: ManualJobDraft;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateJobInput {
|
||||
status?: JobStatus;
|
||||
jobDescription?: string;
|
||||
@ -149,6 +172,10 @@ export interface PipelineConfig {
|
||||
sources: JobSource[]; // Job sources to crawl
|
||||
profilePath: string; // Path to profile JSON
|
||||
outputDir: string; // Directory for generated PDFs
|
||||
enableCrawling?: boolean;
|
||||
enableScoring?: boolean;
|
||||
enableImporting?: boolean;
|
||||
enableAutoTailoring?: boolean;
|
||||
}
|
||||
|
||||
export interface PipelineRun {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user