diff --git a/documentation/extractors/README.md b/documentation/extractors/README.md index f6f5196..f27ca9e 100644 --- a/documentation/extractors/README.md +++ b/documentation/extractors/README.md @@ -5,3 +5,4 @@ Technical breakdowns of how each extractor works. - Gradcracker: `gradcracker.md` - JobSpy: `jobspy.md` - UKVisaJobs: `ukvisajobs.md` +- Manual Import: `manual.md` diff --git a/documentation/extractors/manual.md b/documentation/extractors/manual.md new file mode 100644 index 0000000..9556256 --- /dev/null +++ b/documentation/extractors/manual.md @@ -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. diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 56111d7..0b7c38c 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { + return fetchApi('/manual-jobs/infer', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function importManualJob(input: { + job: ManualJobDraft; +}): Promise { + return fetchApi('/manual-jobs/import', { + method: 'POST', + body: JSON.stringify(input), + }); +} + // Settings & Profile API export async function getSettings(): Promise { return fetchApi('/settings'); diff --git a/orchestrator/src/client/components/DiscoveredPanel.tsx b/orchestrator/src/client/components/DiscoveredPanel.tsx index d04e284..80f8cc3 100644 --- a/orchestrator/src/client/components/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/DiscoveredPanel.tsx @@ -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 = { indeed: "Indeed", linkedin: "LinkedIn", ukvisajobs: "UK Visa Jobs", + manual: "Manual", }; // ───────────────────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 7fd9c57..ec014e7 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -60,6 +60,7 @@ export const Header: React.FC = ({ indeed: "Indeed", linkedin: "LinkedIn", ukvisajobs: "UK Visa Jobs", + manual: "Manual", }; const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; diff --git a/orchestrator/src/client/components/ManualImportSheet.test.tsx b/orchestrator/src/client/components/ManualImportSheet.test.tsx new file mode 100644 index 0000000..a2f2967 --- /dev/null +++ b/orchestrator/src/client/components/ManualImportSheet.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + }); +}); diff --git a/orchestrator/src/client/components/ManualImportSheet.tsx b/orchestrator/src/client/components/ManualImportSheet.tsx new file mode 100644 index 0000000..a582f5a --- /dev/null +++ b/orchestrator/src/client/components/ManualImportSheet.tsx @@ -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; +} + +export const ManualImportSheet: React.FC = ({ + open, + onOpenChange, + onImported, +}) => { + const [step, setStep] = useState("paste"); + const [rawDescription, setRawDescription] = useState(""); + const [draft, setDraft] = useState(emptyDraft); + const [warning, setWarning] = useState(null); + const [error, setError] = useState(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 ( + + +
+ + + + + + Manual Import + + + Paste a job description, review the AI draft, then import the role. + + + +
+
+
+ Step {stepIndex + 1} of 3 + {stepLabel} +
+
+
+
+
+ +
+ +
+ {step === "paste" && ( +
+
+ +