diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index e570fb2..b6223ef 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -85,6 +85,12 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro }); } +export async function rescoreJob(id: string): Promise { + return fetchApi(`/jobs/${id}/rescore`, { + method: 'POST', + }); +} + export async function summarizeJob(id: string, options?: { force?: boolean }): Promise { const query = options?.force ? '?force=1' : ''; return fetchApi(`/jobs/${id}/summarize${query}`, { diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx new file mode 100644 index 0000000..b479701 --- /dev/null +++ b/orchestrator/src/client/components/ReadyPanel.test.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { ReadyPanel } from "./ReadyPanel"; +import type { Job } from "../../shared/types"; +import * as api from "../api"; + +vi.mock("@/components/ui/dropdown-menu", () => { + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +vi.mock("../hooks/useProfile", () => ({ + useProfile: () => ({ personName: "Test" }), +})); + +vi.mock("../hooks/useSettings", () => ({ + useSettings: () => ({ showSponsorInfo: false }), +})); + +vi.mock("../api", () => ({ + rescoreJob: vi.fn(), + getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), + markAsApplied: vi.fn(), + generateJobPdf: vi.fn(), + checkSponsor: vi.fn(), + skipJob: vi.fn(), + updateJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => ({ + id: "job-1", + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Backend Engineer", + employer: "Acme", + employerUrl: null, + jobUrl: "https://example.com/job", + applicationLink: "https://example.com/apply", + disciplines: null, + deadline: "2025-02-01", + salary: "GBP 50k", + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Build APIs", + status: "ready", + suitabilityScore: 82, + suitabilityReason: "Strong fit", + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: 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: "2025-01-01T00:00:00Z", + processedAt: null, + appliedAt: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + ...overrides, +}); + +describe("ReadyPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-runs the fit assessment from the menu", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const job = createJob(); + vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); + + render( + + ); + + fireEvent.click(screen.getByRole("menuitem", { name: /re-run fit assessment/i })); + + await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1")); + expect(onJobUpdated).toHaveBeenCalled(); + }); +}); diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 644c986..0b3aacf 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -67,6 +67,7 @@ export const ReadyPanel: React.FC = ({ const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); + const [isRescoring, setIsRescoring] = useState(false); const [catalog, setCatalog] = useState([]); const [recentlyApplied, setRecentlyApplied] = useState<{ jobId: string; @@ -181,6 +182,22 @@ export const ReadyPanel: React.FC = ({ } }, [job, onJobUpdated]); + const handleRescore = useCallback(async () => { + if (!job) return; + + try { + setIsRescoring(true); + await api.rescoreJob(job.id); + toast.success("Fit assessment updated"); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to re-run fit assessment"; + toast.error(message); + } finally { + setIsRescoring(false); + } + }, [job, onJobUpdated]); + const handleSkip = useCallback(async () => { if (!job) return; @@ -385,6 +402,14 @@ export const ReadyPanel: React.FC = ({ {isRegenerating ? "Regenerating..." : "Regenerate PDF"} + + + {isRescoring ? "Re-scoring..." : "Re-run fit assessment"} + + {/* Utility actions */} diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 777ea89..df77162 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -1,7 +1,13 @@ import React, { useMemo, useState } from "react"; -import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react"; +import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { FitAssessment, JobHeader, TailoredSummary } from ".."; @@ -14,6 +20,8 @@ interface DecideModeProps { onTailor: () => void; onSkip: () => void; isSkipping: boolean; + onRescore: () => void; + isRescoring: boolean; onCheckSponsor?: () => Promise; } @@ -22,6 +30,8 @@ export const DecideMode: React.FC = ({ onTailor, onSkip, isSkipping, + onRescore, + isRescoring, onCheckSponsor, }) => { const [showDescription, setShowDescription] = useState(false); @@ -87,7 +97,26 @@ export const DecideMode: React.FC = ({ -
+
+ + + + + + + + {isRescoring ? "Re-scoring..." : "Re-run fit assessment"} + + + + {jobLink ? ( ); }; - diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx new file mode 100644 index 0000000..116a8ca --- /dev/null +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { DiscoveredPanel } from "./DiscoveredPanel"; +import type { Job } from "../../../shared/types"; +import * as api from "../../api"; + +vi.mock("@/components/ui/dropdown-menu", () => { + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +vi.mock("../../hooks/useSettings", () => ({ + useSettings: () => ({ showSponsorInfo: false }), +})); + +vi.mock("../../api", () => ({ + rescoreJob: vi.fn(), + skipJob: vi.fn(), + processJob: vi.fn(), + checkSponsor: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => ({ + id: "job-2", + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Backend Engineer", + employer: "Acme", + employerUrl: null, + jobUrl: "https://example.com/job", + applicationLink: "https://example.com/apply", + disciplines: null, + deadline: null, + salary: null, + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Build APIs", + status: "discovered", + suitabilityScore: 55, + suitabilityReason: "Ok fit", + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: 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: "2025-01-01T00:00:00Z", + processedAt: null, + appliedAt: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + ...overrides, +}); + +describe("DiscoveredPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-runs the fit assessment from the menu", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const job = createJob(); + vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); + + render( + + ); + + fireEvent.click(screen.getByRole("menuitem", { name: /re-run fit assessment/i })); + + await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2")); + expect(onJobUpdated).toHaveBeenCalled(); + }); +}); diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 3065cec..4ad454a 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -24,11 +24,13 @@ export const DiscoveredPanel: React.FC = ({ const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false); + const [isRescoring, setIsRescoring] = useState(false); useEffect(() => { setMode("decide"); setIsSkipping(false); setIsFinalizing(false); + setIsRescoring(false); }, [job?.id]); const handleSkip = async () => { @@ -69,6 +71,22 @@ export const DiscoveredPanel: React.FC = ({ } }; + const handleRescore = async () => { + if (!job) return; + try { + setIsRescoring(true); + await api.rescoreJob(job.id); + toast.success("Fit assessment updated"); + await onJobUpdated(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to re-run fit assessment"; + toast.error(message); + } finally { + setIsRescoring(false); + } + }; + if (!job) { return ; } @@ -85,6 +103,8 @@ export const DiscoveredPanel: React.FC = ({ onTailor={() => setMode("tailor")} onSkip={handleSkip} isSkipping={isSkipping} + onRescore={handleRescore} + isRescoring={isRescoring} onCheckSponsor={async () => { await api.checkSponsor(job.id); await onJobUpdated(); diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 7fba136..7ad5d6e 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -96,6 +96,32 @@ describe.sequential('Jobs API routes', () => { ); }); + it('rescoring a job updates the suitability fields', async () => { + const { createJob } = await import('../../repositories/jobs.js'); + const { scoreJobSuitability } = await import('../../services/scorer.js'); + const { getProfile } = await import('../../services/profile.js'); + + vi.mocked(getProfile).mockResolvedValue({}); + vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 77, reason: 'Updated fit' }); + + const job = await createJob({ + source: 'manual', + title: 'Test Role', + employer: 'Acme', + jobUrl: 'https://example.com/job/5', + jobDescription: 'Test description', + suitabilityScore: 55, + suitabilityReason: 'Old fit', + }); + + const res = await fetch(`${baseUrl}/api/jobs/${job.id}/rescore`, { method: 'POST' }); + const body = await res.json(); + + expect(body.success).toBe(true); + expect(body.data.suitabilityScore).toBe(77); + expect(body.data.suitabilityReason).toBe('Updated fit'); + }); + it('checks visa sponsor status for a job', async () => { const { searchSponsors } = await import('../../services/visa-sponsors/index.js'); vi.mocked(searchSponsors).mockReturnValue([ diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index 366cbcc..77b9003 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -4,6 +4,8 @@ import * as jobsRepo from '../../repositories/jobs.js'; import * as settingsRepo from '../../repositories/settings.js'; import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js'; import { createNotionEntry } from '../../services/notion.js'; +import { scoreJobSuitability } from '../../services/scorer.js'; +import { getProfile } from '../../services/profile.js'; import * as visaSponsors from '../../services/visa-sponsors/index.js'; import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js'; @@ -139,6 +141,40 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => { } }); +/** + * POST /api/jobs/:id/rescore - Regenerate suitability score + reason + */ +jobsRouter.post('/:id/rescore', async (req: Request, res: Response) => { + try { + const job = await jobsRepo.getJobById(req.params.id); + + if (!job) { + return res.status(404).json({ success: false, error: 'Job not found' }); + } + + const rawProfile = await getProfile(); + if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { + return res.status(400).json({ success: false, error: 'Invalid resume profile format' }); + } + + const { score, reason } = await scoreJobSuitability(job, rawProfile as Record); + + const updatedJob = await jobsRepo.updateJob(job.id, { + suitabilityScore: score, + suitabilityReason: reason, + }); + + if (!updatedJob) { + return res.status(404).json({ success: false, error: 'Job not found' }); + } + + res.json({ success: true, data: updatedJob }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } +}); + /** * POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor */ diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 8787a9d..7750b68 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -45,6 +45,10 @@ vi.mock('../../services/scorer.js', () => ({ scoreJobSuitability: vi.fn(), })); +vi.mock('../../services/profile.js', () => ({ + getProfile: vi.fn().mockResolvedValue({}), +})); + vi.mock('../../services/ukvisajobs.js', () => ({ fetchUkVisaJobsPage: vi.fn(), }));