Merge pull request #45 from DaKheera47/rerun-fit-assessment-and-scoring

Rerun fit assessment and scoring
This commit is contained in:
Shaheer Sarfaraz 2026-01-25 12:06:29 +00:00 committed by GitHub
commit e3e70f5b60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 465 additions and 3 deletions

View File

@ -85,6 +85,12 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro
}); });
} }
export async function rescoreJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/rescore`, {
method: 'POST',
});
}
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> { export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
const query = options?.force ? '?force=1' : ''; const query = options?.force ? '?force=1' : '';
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, { return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {

View File

@ -0,0 +1,142 @@
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";
import { toast } from "sonner";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
...props
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
};
});
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> = {}): 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(
<ReadyPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
});
});

View File

@ -46,6 +46,7 @@ import * as api from "../api";
import { FitAssessment, JobHeader, TailoredSummary } from "."; import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode"; import { TailorMode } from "./discovered-panel/TailorMode";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
type PanelMode = "ready" | "tailor"; type PanelMode = "ready" | "tailor";
@ -67,6 +68,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const [mode, setMode] = useState<PanelMode>("ready"); const [mode, setMode] = useState<PanelMode>("ready");
const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isMarkingApplied, setIsMarkingApplied] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false);
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]); const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [recentlyApplied, setRecentlyApplied] = useState<{ const [recentlyApplied, setRecentlyApplied] = useState<{
jobId: string; jobId: string;
@ -181,6 +183,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
} }
}, [job, onJobUpdated]); }, [job, onJobUpdated]);
const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]);
const handleSkip = useCallback(async () => { const handleSkip = useCallback(async () => {
if (!job) return; if (!job) return;
@ -385,6 +389,14 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
{isRegenerating ? "Regenerating..." : "Regenerate PDF"} {isRegenerating ? "Regenerating..." : "Regenerate PDF"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onSelect={handleRescore}
disabled={isRescoring}
>
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
{isRescoring ? "Recalculating..." : "Recalculate match"}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Utility actions */} {/* Utility actions */}

View File

@ -1,7 +1,13 @@
import React, { useMemo, useState } from "react"; 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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { FitAssessment, JobHeader, TailoredSummary } from ".."; import { FitAssessment, JobHeader, TailoredSummary } from "..";
@ -14,6 +20,8 @@ interface DecideModeProps {
onTailor: () => void; onTailor: () => void;
onSkip: () => void; onSkip: () => void;
isSkipping: boolean; isSkipping: boolean;
onRescore: () => void;
isRescoring: boolean;
onCheckSponsor?: () => Promise<void>; onCheckSponsor?: () => Promise<void>;
} }
@ -22,6 +30,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onTailor, onTailor,
onSkip, onSkip,
isSkipping, isSkipping,
onRescore,
isRescoring,
onCheckSponsor, onCheckSponsor,
}) => { }) => {
const [showDescription, setShowDescription] = useState(false); const [showDescription, setShowDescription] = useState(false);
@ -87,7 +97,26 @@ export const DecideMode: React.FC<DecideModeProps> = ({
<Separator className='opacity-40' /> <Separator className='opacity-40' />
<div className='pt-6 pb-2'> <div className='pt-4 pb-2 space-y-4'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full h-8 gap-2 text-xs text-muted-foreground hover:text-foreground justify-center"
>
More actions
<ChevronUp className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
<RefreshCcw className={isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"} />
{isRescoring ? "Recalculating..." : "Recalculate match"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{jobLink ? ( {jobLink ? (
<div className='flex justify-center'> <div className='flex justify-center'>
<a <a
@ -105,4 +134,3 @@ export const DecideMode: React.FC<DecideModeProps> = ({
</div> </div>
); );
}; };

View File

@ -0,0 +1,135 @@
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";
import { toast } from "sonner";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
...props
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
};
});
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> = {}): 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(
<DiscoveredPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
expect(onJobUpdated).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
});
});

View File

@ -7,6 +7,7 @@ import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
import { ProcessingState } from "./ProcessingState"; import { ProcessingState } from "./ProcessingState";
import { TailorMode } from "./TailorMode"; import { TailorMode } from "./TailorMode";
import { useRescoreJob } from "../../hooks/useRescoreJob";
type PanelMode = "decide" | "tailor"; type PanelMode = "decide" | "tailor";
@ -24,6 +25,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
const [mode, setMode] = useState<PanelMode>("decide"); const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false); const [isSkipping, setIsSkipping] = useState(false);
const [isFinalizing, setIsFinalizing] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false);
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
useEffect(() => { useEffect(() => {
setMode("decide"); setMode("decide");
@ -69,6 +71,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
} }
}; };
const handleRescore = () => rescoreJob(job?.id);
if (!job) { if (!job) {
return <EmptyState />; return <EmptyState />;
} }
@ -85,6 +89,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onTailor={() => setMode("tailor")} onTailor={() => setMode("tailor")}
onSkip={handleSkip} onSkip={handleSkip}
isSkipping={isSkipping} isSkipping={isSkipping}
onRescore={handleRescore}
isRescoring={isRescoring}
onCheckSponsor={async () => { onCheckSponsor={async () => {
await api.checkSponsor(job.id); await api.checkSponsor(job.id);
await onJobUpdated(); await onJobUpdated();

View File

@ -0,0 +1,38 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useRescoreJob } from "./useRescoreJob";
import * as api from "../api";
import { toast } from "sonner";
vi.mock("../api", () => ({
rescoreJob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("useRescoreJob", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rescoring updates the job and shows a toast", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
const { result } = renderHook(() => useRescoreJob(onJobUpdated));
await act(async () => {
await result.current.rescoreJob("job-1");
});
expect(api.rescoreJob).toHaveBeenCalledWith("job-1");
expect(onJobUpdated).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
});
});

View File

@ -0,0 +1,29 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
import * as api from "../api";
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
const [isRescoring, setIsRescoring] = useState(false);
const rescoreJob = useCallback(
async (jobId?: string | null) => {
if (!jobId) return;
try {
setIsRescoring(true);
await api.rescoreJob(jobId);
toast.success("Match recalculated");
await onJobUpdated();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to recalculate match";
toast.error(message);
} finally {
setIsRescoring(false);
}
},
[onJobUpdated],
);
return { isRescoring, rescoreJob };
}

View File

@ -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 () => { it('checks visa sponsor status for a job', async () => {
const { searchSponsors } = await import('../../services/visa-sponsors/index.js'); const { searchSponsors } = await import('../../services/visa-sponsors/index.js');
vi.mocked(searchSponsors).mockReturnValue([ vi.mocked(searchSponsors).mockReturnValue([

View File

@ -4,6 +4,8 @@ import * as jobsRepo from '../../repositories/jobs.js';
import * as settingsRepo from '../../repositories/settings.js'; import * as settingsRepo from '../../repositories/settings.js';
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js'; import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
import { createNotionEntry } from '../../services/notion.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 * as visaSponsors from '../../services/visa-sponsors/index.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.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<string, unknown>);
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 * POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
*/ */

View File

@ -45,6 +45,10 @@ vi.mock('../../services/scorer.js', () => ({
scoreJobSuitability: vi.fn(), scoreJobSuitability: vi.fn(),
})); }));
vi.mock('../../services/profile.js', () => ({
getProfile: vi.fn().mockResolvedValue({}),
}));
vi.mock('../../services/ukvisajobs.js', () => ({ vi.mock('../../services/ukvisajobs.js', () => ({
fetchUkVisaJobsPage: vi.fn(), fetchUkVisaJobsPage: vi.fn(),
})); }));