From 0eba05a1ddc084c9572cd817da83e6f13b4a2b5d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 18:15:21 +0000 Subject: [PATCH 01/76] [HOTFIX] update base url to ensure pdf generation keeps working on reactive resume v4 --- .env.example | 2 +- README.md | 2 +- resume-generator/rxresume_automation.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 9c5d7b0..7960390 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ OPENROUTER_API_KEY=your_openrouter_api_key_here MODEL=openai/gpt-4o-mini # RXResume credentials for PDF generation -# Create an account at: https://rxresu.me +# Create an account at: https://v4.rxresu.me RXRESUME_EMAIL=your_email@example.com RXRESUME_PASSWORD=your_password_here diff --git a/README.md b/README.md index 7209044..b761f3d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ AI-powered job discovery and application pipeline. Automatically finds jobs, sco 1. **Search**: Scrapes Gradcracker, Indeed, LinkedIn, and UK Visa Sponsorship jobs. 2. **Score**: AI ranks jobs by suitability using OpenRouter. 3. **Tailor**: Generates a custom resume summary for top-tier matches. -4. **Export**: Automates [RxResume](https://rxresu.me) to create tailored PDFs. +4. **Export**: Automates [RxResume](https://v4.rxresu.me) to create tailored PDFs. 5. **Manage**: Review and mark jobs as "Applied" via the dashboard (syncs to Notion). ## Example of generating a tailored resume for a job diff --git a/resume-generator/rxresume_automation.py b/resume-generator/rxresume_automation.py index 7f2262e..f7f931e 100644 --- a/resume-generator/rxresume_automation.py +++ b/resume-generator/rxresume_automation.py @@ -28,7 +28,7 @@ OUTPUT_DIR = Path(_custom_output_dir) if _custom_output_dir else BASE_DIR / "res def login(page): """Log in to RXResume.""" - page.goto("https://rxresu.me/auth/login") + page.goto("https://v4.rxresu.me/auth/login") page.fill('input[placeholder="john.doe@example.com"]', RXRESUME_EMAIL) page.fill('input[type="password"]', RXRESUME_PASSWORD) page.click('button:has-text("Sign in")') @@ -47,7 +47,7 @@ def import_resume(page, json_path: Path): def navigate_to_top_resume(page): """Navigate to the first resume in the editor.""" if "/dashboard/resumes" not in page.url: - page.goto("https://rxresu.me/dashboard/resumes") + page.goto("https://v4.rxresu.me/dashboard/resumes") page.wait_for_load_state("networkidle") # wait a beat for the list to update From 1fd6a4b4c2d9946fef29272384e27837eb304a3d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:11:09 +0000 Subject: [PATCH 02/76] initial commit --- orchestrator/package-lock.json | 76 +++++++++++ orchestrator/package.json | 1 + orchestrator/src/client/api/client.ts | 7 + .../src/client/components/JobHeader.tsx | 126 +++++++++++++++++- .../src/client/components/ReadyPanel.tsx | 11 +- .../discovered-panel/DecideMode.tsx | 4 +- .../discovered-panel/DiscoveredPanel.tsx | 4 + .../client/pages/OrchestratorPage.test.tsx | 4 +- .../src/client/pages/SettingsPage.test.tsx | 3 + .../src/client/pages/SettingsPage.tsx | 24 +++- .../orchestrator/JobDetailPanel.test.tsx | 2 + .../pages/orchestrator/JobDetailPanel.tsx | 8 +- .../pages/orchestrator/JobListPanel.test.tsx | 2 + .../components/DisplaySettingsSection.tsx | 77 +++++++++++ orchestrator/src/components/ui/tooltip.tsx | 32 +++++ .../src/server/api/routes/jobs.test.ts | 2 +- orchestrator/src/server/api/routes/jobs.ts | 59 ++++++++ .../src/server/api/routes/settings.ts | 38 +++++- orchestrator/src/server/db/migrate.ts | 4 + orchestrator/src/server/db/schema.ts | 2 + .../src/server/pipeline/orchestrator.ts | 28 +++- orchestrator/src/server/repositories/jobs.ts | 28 ++-- .../src/server/repositories/settings.ts | 1 + .../src/server/services/ai-resilience.test.ts | 30 +++-- orchestrator/src/shared/types.ts | 9 +- 25 files changed, 537 insertions(+), 45 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx create mode 100644 orchestrator/src/components/ui/tooltip.tsx diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 5322da3..2af6a58 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2231,6 +2232,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2359,6 +2412,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index 1130faf..d810575 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 9b3cae5..6558b78 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise { }); } +export async function checkSponsor(id: string): Promise { + return fetchApi(`/jobs/${id}/check-sponsor`, { + method: 'POST', + }); +} + export async function markAsApplied(id: string): Promise { return fetchApi(`/jobs/${id}/apply`, { method: 'POST', @@ -186,6 +192,7 @@ export async function updateSettings(update: { jobspyCountryIndeed?: string | null jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null + showSponsorInfo?: boolean | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 3d1f9be..6437604 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,6 +1,8 @@ -import React from "react"; -import { Calendar, DollarSign, MapPin } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, formatDate, sourceLabel } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants"; @@ -8,6 +10,8 @@ import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constant interface JobHeaderProps { job: Job; className?: string; + showSponsorInfo?: boolean; + onCheckSponsor?: () => Promise; } const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { @@ -42,7 +46,112 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { ); }; -export const JobHeader: React.FC = ({ job, className }) => { +interface SponsorBadgeProps { + score: number | null; + names: string | null; + onCheck?: () => Promise; +} + +const SponsorBadge: React.FC = ({ score, names, onCheck }) => { + const [isChecking, setIsChecking] = useState(false); + + const parsedNames = useMemo(() => { + if (!names) return []; + try { + return JSON.parse(names) as string[]; + } catch { + return []; + } + }, [names]); + + const handleCheck = async () => { + if (!onCheck) return; + setIsChecking(true); + try { + await onCheck(); + } finally { + setIsChecking(false); + } + }; + + // Show "Check" button if no score and callback provided + if (score == null && onCheck) { + return ( + + + + + + +

Check if employer is a visa sponsor

+
+
+
+ ); + } + + // If no score (and no callback), show nothing + if (score == null || score < 50) { + return null; + } + + // Color tokens based on score + const getScoreTokens = (s: number) => { + if (s >= 90) return { + badge: "border-emerald-500/40 bg-emerald-500/15 text-emerald-300", + label: "Visa Sponsor" + }; + if (s >= 70) return { + badge: "border-amber-500/40 bg-amber-500/15 text-amber-300", + label: "Likely Sponsor" + }; + return { + badge: "border-orange-500/40 bg-orange-500/15 text-orange-300", + label: "Possible Sponsor" + }; + }; + + const tokens = getScoreTokens(score); + const tooltipContent = parsedNames.length > 0 + ? `${score}% match: ${parsedNames.join(", ")}` + : `${score}% match with visa sponsor list`; + + return ( + + + + + + {score}% + + + +

{tooltipContent}

+
+
+
+ ); +}; + +export const JobHeader: React.FC = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => { const deadline = formatDate(job.deadline); return ( @@ -51,7 +160,16 @@ export const JobHeader: React.FC = ({ job, className }) => {
{job.title}
-
{job.employer}
+
+ {job.employer} + {showSponsorInfo && ( + + )} +
{sourceLabel[job.source]} diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index c3f0de6..acc2ade 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -141,7 +141,7 @@ export const ReadyPanel: React.FC = ({ // Revert to ready status await api.updateJob(jobId, { status: "ready" }); toast.success("Reverted to Ready"); - + if (recentlyApplied?.timeoutId) { clearTimeout(recentlyApplied.timeoutId); } @@ -215,7 +215,14 @@ export const ReadyPanel: React.FC = ({ return (
- + { + await api.checkSponsor(job.id); + await onJobUpdated(); + }} + /> {/* ───────────────────────────────────────────────────────────────────── PRIMARY ACTION CLUSTER diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index df9aced..4308e4e 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -14,6 +14,7 @@ interface DecideModeProps { onTailor: () => void; onSkip: () => void; isSkipping: boolean; + onCheckSponsor?: () => Promise; } export const DecideMode: React.FC = ({ @@ -21,6 +22,7 @@ export const DecideMode: React.FC = ({ onTailor, onSkip, isSkipping, + onCheckSponsor, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -33,7 +35,7 @@ export const DecideMode: React.FC = ({ return (
- +
@@ -103,34 +103,30 @@ const SponsorBadge: React.FC = ({ score, names, onCheck }) => ); } - // If no score (and no callback), or error in score, show nothing if (score == null) { return null; } - const isFound = score >= 95; - const tooltipContent = isFound - ? `Confirmed Visa Sponsor (${score}% match: ${parsedNames.join(", ")})` - : `Sponsor Not Found (${score}% match${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""})`; + const canSponsor = score >= 95; + const label = canSponsor ? "Can Sponsor" : "Unsure if Sponsor"; + const dotClass = canSponsor ? "bg-emerald-500" : "bg-slate-500"; + const tooltipContent = canSponsor + ? `${score}% match` + : `Closest: ${parsedNames.join(", ")} (${score}% match)`; return ( - + - - + + + {label} -

{isFound ? "Found" : "Not Found"}

-

{tooltipContent}

+ {canSponsor &&

{parsedNames.join(", ")}

} + {!canSponsor &&

Unsure if sponsor

} +

{tooltipContent}

@@ -148,13 +144,6 @@ export const JobHeader: React.FC = ({ job, className, showSponso
{job.title}
{job.employer} - {showSponsorInfo && ( - - )}
@@ -186,7 +175,16 @@ export const JobHeader: React.FC = ({ job, className, showSponso {/* Status and score: single line, subdued */}
- +
+ + {showSponsorInfo && ( + + )} +
diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index acc2ade..068d4ea 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -50,6 +50,7 @@ interface ReadyPanelProps { onJobMoved: (jobId: string) => void; onEditTailoring: () => void; onEditDescription: () => void; + showSponsorInfo?: boolean; } const safeFilenamePart = (value: string | null | undefined) => @@ -61,6 +62,7 @@ export const ReadyPanel: React.FC = ({ onJobMoved, onEditTailoring, onEditDescription, + showSponsorInfo, }) => { const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); @@ -222,6 +224,7 @@ export const ReadyPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} + showSponsorInfo={showSponsorInfo} /> {/* ───────────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 4308e4e..6d7418b 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -15,6 +15,7 @@ interface DecideModeProps { onSkip: () => void; isSkipping: boolean; onCheckSponsor?: () => Promise; + showSponsorInfo?: boolean; } export const DecideMode: React.FC = ({ @@ -23,6 +24,7 @@ export const DecideMode: React.FC = ({ onSkip, isSkipping, onCheckSponsor, + showSponsorInfo, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -35,7 +37,11 @@ export const DecideMode: React.FC = ({ return (
- +
)} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index e7a1297..563c0bc 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -41,6 +41,7 @@ interface JobDetailPanelProps { onSelectJobId: (jobId: string | null) => void; onJobUpdated: () => Promise; onSetActiveTab: (tab: FilterTab) => void; + showSponsorInfo?: boolean; } export const JobDetailPanel: React.FC = ({ @@ -50,6 +51,7 @@ export const JobDetailPanel: React.FC = ({ onSelectJobId, onJobUpdated, onSetActiveTab, + showSponsorInfo, }) => { const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [isEditingDescription, setIsEditingDescription] = useState(false); @@ -233,6 +235,7 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} + showSponsorInfo={showSponsorInfo} /> ); } @@ -254,6 +257,7 @@ export const JobDetailPanel: React.FC = ({ setIsEditingDescription(true); }, 50); }} + showSponsorInfo={showSponsorInfo} /> ); } @@ -275,6 +279,7 @@ export const JobDetailPanel: React.FC = ({ await api.checkSponsor(selectedJob.id); await onJobUpdated(); }} + showSponsorInfo={showSponsorInfo} />
diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index 2c440cb..0f6dac9 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { Job, JobStatus } from "../../../shared/types"; +import type { Job, JobStatus, AppSettings } from "../../../shared/types"; import * as api from "../../api"; const initialStats: Record = { @@ -16,15 +16,20 @@ const initialStats: Record = { export const useOrchestratorData = () => { const [jobs, setJobs] = useState([]); const [stats, setStats] = useState>(initialStats); + const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const loadJobs = useCallback(async () => { try { setIsLoading(true); - const data = await api.getJobs(); - setJobs(data.jobs); - setStats(data.byStatus); + const [jobsData, settingsData] = await Promise.all([ + api.getJobs(), + api.getSettings(), + ]); + setJobs(jobsData.jobs); + setStats(jobsData.byStatus); + setSettings(settingsData); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); @@ -54,5 +59,5 @@ export const useOrchestratorData = () => { return () => clearInterval(interval); }, [loadJobs, checkPipelineStatus]); - return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; + return { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; }; From 158e4b108b5c9aaaf9a4ca4271db97725963564d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:46:50 +0000 Subject: [PATCH 05/76] reduce prop drilling --- .../src/client/components/JobHeader.tsx | 14 +++-- .../src/client/components/ReadyPanel.tsx | 3 - .../discovered-panel/DecideMode.tsx | 3 - .../discovered-panel/DiscoveredPanel.tsx | 3 - orchestrator/src/client/hooks/useSettings.ts | 58 +++++++++++++++++++ .../src/client/pages/OrchestratorPage.tsx | 5 +- .../pages/orchestrator/JobDetailPanel.tsx | 5 -- .../pages/orchestrator/useOrchestratorData.ts | 15 ++--- 8 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 orchestrator/src/client/hooks/useSettings.ts diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 704d9d4..f42da72 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -7,10 +7,11 @@ import { cn, formatDate, sourceLabel } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants"; +import { useSettings } from "../hooks/useSettings"; + interface JobHeaderProps { job: Job; className?: string; - showSponsorInfo?: boolean; onCheckSponsor?: () => Promise; } @@ -83,16 +84,16 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { @@ -133,7 +134,8 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { ); }; -export const JobHeader: React.FC = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => { +export const JobHeader: React.FC = ({ job, className, onCheckSponsor }) => { + const { showSponsorInfo } = useSettings(); const deadline = formatDate(job.deadline); return ( diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 068d4ea..acc2ade 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -50,7 +50,6 @@ interface ReadyPanelProps { onJobMoved: (jobId: string) => void; onEditTailoring: () => void; onEditDescription: () => void; - showSponsorInfo?: boolean; } const safeFilenamePart = (value: string | null | undefined) => @@ -62,7 +61,6 @@ export const ReadyPanel: React.FC = ({ onJobMoved, onEditTailoring, onEditDescription, - showSponsorInfo, }) => { const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); @@ -224,7 +222,6 @@ export const ReadyPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} /> {/* ───────────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 6d7418b..777ea89 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -15,7 +15,6 @@ interface DecideModeProps { onSkip: () => void; isSkipping: boolean; onCheckSponsor?: () => Promise; - showSponsorInfo?: boolean; } export const DecideMode: React.FC = ({ @@ -24,7 +23,6 @@ export const DecideMode: React.FC = ({ onSkip, isSkipping, onCheckSponsor, - showSponsorInfo, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -40,7 +38,6 @@ export const DecideMode: React.FC = ({
diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 9593b0f..3065cec 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -14,14 +14,12 @@ interface DiscoveredPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; - showSponsorInfo?: boolean; } export const DiscoveredPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - showSponsorInfo, }) => { const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); @@ -91,7 +89,6 @@ export const DiscoveredPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} /> ) : ( void> = new Set(); +let isFetching = false; + +export function useSettings() { + const [settings, setSettings] = useState(settingsCache); + + useEffect(() => { + if (settingsCache) { + setSettings(settingsCache); + } + + const handleUpdate = (newSettings: AppSettings) => { + setSettings(newSettings); + }; + + subscribers.add(handleUpdate); + + if (!settingsCache && !isFetching) { + isFetching = true; + api.getSettings() + .then((data) => { + settingsCache = data; + subscribers.forEach(sub => sub(data)); + }) + .finally(() => { + isFetching = false; + }); + } + + return () => { + subscribers.delete(handleUpdate); + }; + }, []); + + const refreshSettings = async () => { + isFetching = true; + try { + const data = await api.getSettings(); + settingsCache = data; + subscribers.forEach(sub => sub(data)); + return data; + } finally { + isFetching = false; + } + }; + + return { + settings, + isLoading: !settings && isFetching, + showSponsorInfo: settings?.showSponsorInfo ?? true, + refreshSettings, + }; +} diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 612fe23..c6fc6ee 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -122,9 +122,7 @@ export const OrchestratorPage: React.FC = () => { }; const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(); - const { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); - - const showSponsorInfo = settings?.showSponsorInfo ?? true; + const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort); const counts = useMemo(() => getJobCounts(jobs), [jobs]); @@ -278,7 +276,6 @@ export const OrchestratorPage: React.FC = () => { onSelectJobId={handleSelectJobId} onJobUpdated={loadJobs} onSetActiveTab={setActiveTab} - showSponsorInfo={showSponsorInfo} />
)} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 563c0bc..e7a1297 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -41,7 +41,6 @@ interface JobDetailPanelProps { onSelectJobId: (jobId: string | null) => void; onJobUpdated: () => Promise; onSetActiveTab: (tab: FilterTab) => void; - showSponsorInfo?: boolean; } export const JobDetailPanel: React.FC = ({ @@ -51,7 +50,6 @@ export const JobDetailPanel: React.FC = ({ onSelectJobId, onJobUpdated, onSetActiveTab, - showSponsorInfo, }) => { const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [isEditingDescription, setIsEditingDescription] = useState(false); @@ -235,7 +233,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - showSponsorInfo={showSponsorInfo} /> ); } @@ -257,7 +254,6 @@ export const JobDetailPanel: React.FC = ({ setIsEditingDescription(true); }, 50); }} - showSponsorInfo={showSponsorInfo} /> ); } @@ -279,7 +275,6 @@ export const JobDetailPanel: React.FC = ({ await api.checkSponsor(selectedJob.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} />
diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index 0f6dac9..2c440cb 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { Job, JobStatus, AppSettings } from "../../../shared/types"; +import type { Job, JobStatus } from "../../../shared/types"; import * as api from "../../api"; const initialStats: Record = { @@ -16,20 +16,15 @@ const initialStats: Record = { export const useOrchestratorData = () => { const [jobs, setJobs] = useState([]); const [stats, setStats] = useState>(initialStats); - const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const loadJobs = useCallback(async () => { try { setIsLoading(true); - const [jobsData, settingsData] = await Promise.all([ - api.getJobs(), - api.getSettings(), - ]); - setJobs(jobsData.jobs); - setStats(jobsData.byStatus); - setSettings(settingsData); + const data = await api.getJobs(); + setJobs(data.jobs); + setStats(data.byStatus); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); @@ -59,5 +54,5 @@ export const useOrchestratorData = () => { return () => clearInterval(interval); }, [loadJobs, checkPipelineStatus]); - return { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; + return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; }; From 06d25b63a228f25d0d4fd66e92204c1f39c8bd91 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:51:36 +0000 Subject: [PATCH 06/76] more granular messages --- .../src/client/components/JobHeader.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index f42da72..bbca08e 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -108,26 +108,32 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { return null; } - const canSponsor = score >= 95; - const label = canSponsor ? "Can Sponsor" : "Unsure if Sponsor"; - const dotClass = canSponsor ? "bg-emerald-500" : "bg-slate-500"; - const tooltipContent = canSponsor - ? `${score}% match` - : `Closest: ${parsedNames.join(", ")} (${score}% match)`; + const getStatus = (s: number) => { + if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" }; + if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" }; + return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" }; + }; + + const status = getStatus(score); + const tooltipContent = `${score}% match${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""}`; return ( - - {label} + + {status.label} - {canSponsor &&

{parsedNames.join(", ")}

} - {!canSponsor &&

Unsure if sponsor

} -

{tooltipContent}

+ {parsedNames.length > 0 && ( +

+ Matched: + {parsedNames.join(", ")} +

+ )} +

{tooltipContent}

From 1106e95ad6a9ba6f94bd95ddd5cb773b97d9a459 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:53:13 +0000 Subject: [PATCH 07/76] better copy --- orchestrator/src/client/components/JobHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index bbca08e..b1e15f1 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -115,7 +115,7 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { }; const status = getStatus(score); - const tooltipContent = `${score}% match${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""}`; + const tooltipContent = `${score}% match`; return ( @@ -129,7 +129,7 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { {parsedNames.length > 0 && (

- Matched: + Matched {parsedNames.join(", ")}

)} From 3d55e786148477a99c87d9c23c1e9f7be15c58d9 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:07:55 +0000 Subject: [PATCH 08/76] tests --- .../src/client/components/JobHeader.test.tsx | 105 ++++++++++++++++++ .../src/client/hooks/useSettings.test.ts | 66 +++++++++++ orchestrator/src/client/hooks/useSettings.ts | 7 ++ .../src/server/api/routes/jobs.test.ts | 22 ++++ 4 files changed, 200 insertions(+) create mode 100644 orchestrator/src/client/components/JobHeader.test.tsx create mode 100644 orchestrator/src/client/hooks/useSettings.test.ts diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx new file mode 100644 index 0000000..b9a0232 --- /dev/null +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { JobHeader } from "./JobHeader"; +import { useSettings } from "../hooks/useSettings"; +import * as api from "../api"; +import type { Job } from "../../shared/types"; + +// Mock useSettings +vi.mock("../hooks/useSettings", () => ({ + useSettings: vi.fn(), +})); + +// Mock api +vi.mock("../api", () => ({ + checkSponsor: vi.fn(), +})); + +// Mock Tooltip components to simplify testing +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const mockJob: Job = { + id: "job-1", + title: "Software Engineer", + employer: "Tech Corp", + location: "London", + salary: "£60,000", + deadline: "2025-12-31", + status: "discovered", + source: "linkedin", + suitabilityScore: 85, + suitabilityReason: "Strong match", + sponsorMatchScore: null, + sponsorMatchNames: null, + // Other fields... +} as Job; + +describe("JobHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + (useSettings as any).mockReturnValue({ + showSponsorInfo: true, + }); + }); + + it("renders basic job information", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Tech Corp")).toBeInTheDocument(); + expect(screen.getByText("London")).toBeInTheDocument(); + expect(screen.getByText("£60,000")).toBeInTheDocument(); + }); + + it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { + const onCheckSponsor = vi.fn().mockResolvedValue(undefined); + render(); + + const button = screen.getByText("Check Sponsorship Status"); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + expect(onCheckSponsor).toHaveBeenCalled(); + }); + + it("shows 'Confirmed Sponsor' when score >= 95", () => { + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' }; + render(); + + expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Potential Sponsor' when score is between 80 and 94", () => { + const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' }; + render(); + + expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Sponsor Not Found' when score < 80", () => { + const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' }; + render(); + + expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); + }); + + it("hides sponsor info when showSponsorInfo is false", () => { + (useSettings as any).mockReturnValue({ + showSponsorInfo: false, + }); + + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; + render(); + + expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); + expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts new file mode 100644 index 0000000..e8aff35 --- /dev/null +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSettings, _resetSettingsCache } from './useSettings'; +import * as api from '../api'; + +vi.mock('../api', () => ({ + getSettings: vi.fn(), +})); + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetSettingsCache(); + }); + + it('fetches settings on mount if not already cached', async () => { + const mockSettings = { showSponsorInfo: false }; + (api.getSettings as any).mockResolvedValue(mockSettings); + + const { result } = renderHook(() => useSettings()); + + // Should start in loading state + expect(result.current.settings).toBeNull(); + + await waitFor(() => { + expect(result.current.settings).toEqual(mockSettings); + }); + + expect(result.current.showSponsorInfo).toBe(false); + expect(api.getSettings).toHaveBeenCalledTimes(1); + }); + + it('uses default values when settings are null', async () => { + (api.getSettings as any).mockResolvedValue(null); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + // settings is null, so showSponsorInfo should default to true + expect(result.current.showSponsorInfo).toBe(true); + }); + }); + + it('provides a refresh function that updates settings', async () => { + const initialSettings = { showSponsorInfo: true }; + const updatedSettings = { showSponsorInfo: false }; + + (api.getSettings as any).mockResolvedValueOnce(initialSettings); + (api.getSettings as any).mockResolvedValueOnce(updatedSettings); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settings).toEqual(initialSettings); + }); + + let refreshed; + await waitFor(async () => { + refreshed = await result.current.refreshSettings(); + }); + + expect(refreshed).toEqual(updatedSettings); + expect(result.current.settings).toEqual(updatedSettings); + expect(result.current.showSponsorInfo).toBe(false); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index c970690..cb8fae3 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -56,3 +56,10 @@ export function useSettings() { refreshSettings, }; } + +/** @internal For testing only */ +export function _resetSettingsCache() { + settingsCache = null; + isFetching = false; + subscribers.clear(); +} diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 14324de..7fba136 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -95,4 +95,26 @@ describe.sequential('Jobs API routes', () => { }) ); }); + + it('checks visa sponsor status for a job', async () => { + const { searchSponsors } = await import('../../services/visa-sponsors/index.js'); + vi.mocked(searchSponsors).mockReturnValue([ + { sponsor: { organisationName: 'ACME CORP SPONSOR' } as any, score: 100, matchedName: 'acme corp sponsor' } + ]); + + const { createJob } = await import('../../repositories/jobs.js'); + const job = await createJob({ + source: 'manual', + title: 'Sponsored Dev', + employer: 'Acme', + jobUrl: 'https://example.com/job/4', + }); + + const res = await fetch(`${baseUrl}/api/jobs/${job.id}/check-sponsor`, { method: 'POST' }); + const body = await res.json(); + + expect(body.success).toBe(true); + expect(body.data.sponsorMatchScore).toBe(100); + expect(body.data.sponsorMatchNames).toContain('ACME CORP SPONSOR'); + }); }); From ba9dcc78676d12aa6518cda0836e3e9893f28351 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:10:02 +0000 Subject: [PATCH 09/76] tests fix --- orchestrator/src/server/api/routes/pipeline.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts index 0d7dc34..68c80f3 100644 --- a/orchestrator/src/server/api/routes/pipeline.test.ts +++ b/orchestrator/src/server/api/routes/pipeline.test.ts @@ -54,11 +54,14 @@ describe.sequential('Pipeline API routes', () => { const reader = res.body?.getReader(); if (reader) { - const chunk = await reader.read(); - controller.abort(); - await reader.cancel(); - const text = new TextDecoder().decode(chunk.value); - expect(text).toContain('data:'); + try { + const { value } = await reader.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('data:'); + } finally { + await reader.cancel(); + controller.abort(); + } } else { controller.abort(); } From 53e16013a85c651bb0fcfc2b1682a0e23606961e Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:17:41 +0000 Subject: [PATCH 10/76] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/services/ai-resilience.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/server/services/ai-resilience.test.ts b/orchestrator/src/server/services/ai-resilience.test.ts index dea96a4..fc45424 100644 --- a/orchestrator/src/server/services/ai-resilience.test.ts +++ b/orchestrator/src/server/services/ai-resilience.test.ts @@ -143,7 +143,7 @@ describe('AI Service Resilience', () => { }) }; vi.mocked(global.fetch).mockResolvedValue(mockResponse as any); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); const result = await scoreJobSuitability(mockJob, mockProfile); From af2ee1fa9c1b53b7806a8e48aa60e92edda2226e Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:17:48 +0000 Subject: [PATCH 11/76] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index b1e15f1..2498049 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } from "lucide-react"; +import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; From 0ec707143a0ed6ce362762424b0e66a89e840e4b Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:18:00 +0000 Subject: [PATCH 12/76] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index b9a0232..0cc701d 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { JobHeader } from "./JobHeader"; import { useSettings } from "../hooks/useSettings"; -import * as api from "../api"; import type { Job } from "../../shared/types"; // Mock useSettings From dd03eed0fbefcd0ee78fa6b2837715b438aa86f9 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:18:08 +0000 Subject: [PATCH 13/76] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index 0cc701d..a96407c 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { JobHeader } from "./JobHeader"; import { useSettings } from "../hooks/useSettings"; import type { Job } from "../../shared/types"; From ba27eb07b1edf44c3f440235183734c3bb7ef351 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:19:03 +0000 Subject: [PATCH 14/76] remove redundant check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/pipeline/orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 08166e9..bfd605a 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -321,8 +321,8 @@ export async function runPipeline(config: Partial = {}): Promise await jobsRepo.updateJob(job.id, { suitabilityScore: score, suitabilityReason: reason, - ...(sponsorMatchScore !== undefined && { sponsorMatchScore }), - ...(sponsorMatchNames !== undefined && { sponsorMatchNames }), + sponsorMatchScore, + sponsorMatchNames, }); } From 6c3bb681d140bca4102368db814177fcd562f7d8 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:23:03 +0000 Subject: [PATCH 15/76] tests --- .../server/pipeline/sponsor-matching.test.ts | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 orchestrator/src/server/pipeline/sponsor-matching.test.ts diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts new file mode 100644 index 0000000..d8c8d00 --- /dev/null +++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for sponsor match calculation logic in the pipeline orchestrator. + * + * These tests verify that during job scoring, the sponsor matching functionality + * correctly calculates and stores sponsor match scores and names. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Job } from '../../shared/types.js'; + +// Mock the visa-sponsors module +vi.mock('../services/visa-sponsors/index.js', () => ({ + searchSponsors: vi.fn(), +})); + +// Mock the scorer module +vi.mock('../services/scorer.js', () => ({ + scoreJobSuitability: vi.fn(), +})); + +// Mock the jobs repository +vi.mock('../repositories/jobs.js', () => ({ + updateJob: vi.fn(), + getUnscoredDiscoveredJobs: vi.fn(), + getJobById: vi.fn(), + bulkCreateJobs: vi.fn(), + getAllJobUrls: vi.fn(), +})); + +// Mock other dependencies to prevent side effects +vi.mock('../repositories/pipeline.js', () => ({ + createPipelineRun: vi.fn(() => ({ id: 'test-run-id' })), + updatePipelineRun: vi.fn(), +})); + +vi.mock('../repositories/settings.js', () => ({ + getSetting: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../services/crawler.js', () => ({ + runCrawler: vi.fn(() => ({ success: true, jobs: [] })), +})); + +vi.mock('../services/jobspy.js', () => ({ + runJobSpy: vi.fn(() => ({ success: true, jobs: [] })), +})); + +vi.mock('../services/ukvisajobs.js', () => ({ + runUkVisaJobs: vi.fn(() => ({ success: true, jobs: [] })), +})); + +const now = new Date().toISOString(); + +// Mock job template +const createMockJob = (overrides: Partial = {}): Job => ({ + id: 'test-job-1', + source: 'gradcracker', + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: 'Software Engineer', + employer: 'Acme Corporation Ltd', + employerUrl: null, + jobUrl: 'http://test.com/job', + applicationLink: null, + disciplines: null, + deadline: null, + salary: null, + location: 'London', + degreeRequired: null, + starting: null, + jobDescription: 'Looking for a TypeScript developer.', + status: 'discovered', + suitabilityScore: null, + suitabilityReason: null, + 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: now, + processedAt: null, + appliedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +describe('Sponsor Match Calculation', () => { + let searchSponsors: ReturnType; + let scoreJobSuitability: ReturnType; + let updateJob: ReturnType; + let getUnscoredDiscoveredJobs: ReturnType; + let bulkCreateJobs: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Get mocked functions + const visaSponsors = await import('../services/visa-sponsors/index.js'); + const scorer = await import('../services/scorer.js'); + const jobsRepo = await import('../repositories/jobs.js'); + + searchSponsors = visaSponsors.searchSponsors as ReturnType; + scoreJobSuitability = scorer.scoreJobSuitability as ReturnType; + updateJob = jobsRepo.updateJob as ReturnType; + getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType; + bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType; + + // Default mock implementations + scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' }); + bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 }); + updateJob.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('searchSponsors integration', () => { + it('should calculate sponsor match score when employer matches a sponsor', async () => { + const mockJob = createMockJob({ employer: 'Acme Corporation Ltd' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + // Mock sponsor search returning a match + searchSponsors.mockReturnValue([ + { + sponsor: { organisationName: 'ACME CORPORATION LIMITED' }, + score: 85, + matchedName: 'acme corporation', + }, + ]); + + // Import and run pipeline + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // Verify searchSponsors was called with correct parameters + expect(searchSponsors).toHaveBeenCalledWith('Acme Corporation Ltd', { + limit: 10, + minScore: 50, + }); + + // Verify updateJob was called with sponsor match data + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + suitabilityScore: 75, + suitabilityReason: 'Good match', + sponsorMatchScore: 85, + sponsorMatchNames: JSON.stringify(['ACME CORPORATION LIMITED']), + }) + ); + }); + + it('should handle 100% perfect matches correctly', async () => { + const mockJob = createMockJob({ employer: 'Microsoft UK' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + // Mock sponsor search returning perfect matches + searchSponsors.mockReturnValue([ + { + sponsor: { organisationName: 'MICROSOFT UK LIMITED' }, + score: 100, + matchedName: 'microsoft uk', + }, + { + sponsor: { organisationName: 'MICROSOFT UK LTD' }, + score: 100, + matchedName: 'microsoft uk', + }, + { + sponsor: { organisationName: 'MICROSOFT LIMITED' }, + score: 80, + matchedName: 'microsoft', + }, + ]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // Should include up to 2 perfect matches + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + sponsorMatchScore: 100, + sponsorMatchNames: JSON.stringify([ + 'MICROSOFT UK LIMITED', + 'MICROSOFT UK LTD', + ]), + }) + ); + }); + + it('should report single top match when no perfect matches exist', async () => { + const mockJob = createMockJob({ employer: 'Tech Corp' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + // Mock sponsor search returning partial matches only + searchSponsors.mockReturnValue([ + { + sponsor: { organisationName: 'TECH CORPORATION' }, + score: 75, + matchedName: 'tech corporation', + }, + { + sponsor: { organisationName: 'TECHNO CORP' }, + score: 60, + matchedName: 'techno corp', + }, + ]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // Should only include the top match since none are 100% + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + sponsorMatchScore: 75, + sponsorMatchNames: JSON.stringify(['TECH CORPORATION']), + }) + ); + }); + + it('should not set sponsor match when no matches found', async () => { + const mockJob = createMockJob({ employer: 'Unknown Company XYZ' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + // Mock sponsor search returning no matches + searchSponsors.mockReturnValue([]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // sponsorMatchScore should be 0 (not set) and sponsorMatchNames undefined + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + suitabilityScore: 75, + suitabilityReason: 'Good match', + }) + ); + + // Verify that sponsorMatchScore is 0 and sponsorMatchNames is not included + // when there are no matches + const updateCall = updateJob.mock.calls[0][1]; + expect(updateCall.sponsorMatchScore).toBe(0); + expect(updateCall.sponsorMatchNames).toBeUndefined(); + }); + + it('should skip sponsor matching when job has no employer', async () => { + const mockJob = createMockJob({ employer: null as unknown as string }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // searchSponsors should not be called + expect(searchSponsors).not.toHaveBeenCalled(); + + // updateJob should still be called but without sponsor data + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + suitabilityScore: 75, + suitabilityReason: 'Good match', + }) + ); + }); + + it('should skip sponsor matching when job has empty employer string', async () => { + const mockJob = createMockJob({ employer: '' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // searchSponsors should not be called for empty string + expect(searchSponsors).not.toHaveBeenCalled(); + }); + }); + + describe('sponsor match edge cases', () => { + it('should use correct limit and minScore options', async () => { + const mockJob = createMockJob({ employer: 'Test Company' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + searchSponsors.mockReturnValue([]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + expect(searchSponsors).toHaveBeenCalledWith('Test Company', { + limit: 10, + minScore: 50, + }); + }); + + it('should handle single 100% match correctly', async () => { + const mockJob = createMockJob({ employer: 'Google UK' }); + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]); + + searchSponsors.mockReturnValue([ + { + sponsor: { organisationName: 'GOOGLE UK LIMITED' }, + score: 100, + matchedName: 'google uk', + }, + ]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // Single perfect match should be reported + expect(updateJob).toHaveBeenCalledWith( + 'test-job-1', + expect.objectContaining({ + sponsorMatchScore: 100, + sponsorMatchNames: JSON.stringify(['GOOGLE UK LIMITED']), + }) + ); + }); + + it('should process multiple jobs with different sponsor matches', async () => { + const mockJob1 = createMockJob({ + id: 'job-1', + employer: 'Amazon UK', + }); + const mockJob2 = createMockJob({ + id: 'job-2', + employer: 'Meta Platforms', + }); + + getUnscoredDiscoveredJobs.mockResolvedValue([mockJob1, mockJob2]); + + // Different results for each employer + searchSponsors + .mockReturnValueOnce([ + { + sponsor: { organisationName: 'AMAZON UK SERVICES LTD' }, + score: 90, + matchedName: 'amazon uk', + }, + ]) + .mockReturnValueOnce([ + { + sponsor: { organisationName: 'META PLATFORMS IRELAND LIMITED' }, + score: 80, + matchedName: 'meta platforms', + }, + ]); + + const { runPipeline } = await import('./orchestrator.js'); + await runPipeline({ sources: [], enableCrawling: false }); + + // Verify both jobs were processed with different sponsor data + expect(updateJob).toHaveBeenCalledTimes(2); + + expect(updateJob).toHaveBeenCalledWith( + 'job-1', + expect.objectContaining({ + sponsorMatchScore: 90, + sponsorMatchNames: JSON.stringify(['AMAZON UK SERVICES LTD']), + }) + ); + + expect(updateJob).toHaveBeenCalledWith( + 'job-2', + expect.objectContaining({ + sponsorMatchScore: 80, + sponsorMatchNames: JSON.stringify(['META PLATFORMS IRELAND LIMITED']), + }) + ); + }); + }); +}); From ad0ca7f183729eb14e08a56a58069beb43d106c8 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:27:57 +0000 Subject: [PATCH 16/76] dedupe and tests --- orchestrator/src/server/api/routes/jobs.ts | 15 +- .../src/server/pipeline/orchestrator.ts | 14 +- .../server/pipeline/sponsor-matching.test.ts | 14 ++ .../services/visa-sponsors/index.test.ts | 107 ++++++++++++++ .../server/services/visa-sponsors/index.ts | 135 ++++++++++-------- 5 files changed, 204 insertions(+), 81 deletions(-) create mode 100644 orchestrator/src/server/services/visa-sponsors/index.test.ts diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index f33e5e5..366cbcc 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -160,20 +160,7 @@ jobsRouter.post('/:id/check-sponsor', async (req: Request, res: Response) => { minScore: 50, }); - let sponsorMatchScore = 0; - let sponsorMatchNames: string | null = null; - - if (sponsorResults.length > 0) { - const topScore = sponsorResults[0].score; - // Get all 100% matches, or just the top match - const perfectMatches = sponsorResults.filter(r => r.score === 100); - const matchesToReport = perfectMatches.length >= 2 - ? perfectMatches.slice(0, 2) - : [sponsorResults[0]]; - - sponsorMatchScore = topScore; - sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)); - } + const { sponsorMatchScore, sponsorMatchNames } = visaSponsors.calculateSponsorMatchSummary(sponsorResults); // Update job with sponsor match info const updatedJob = await jobsRepo.updateJob(job.id, { diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index bfd605a..dd204c9 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -304,17 +304,9 @@ export async function runPipeline(config: Partial = {}): Promise minScore: 50, }); - if (sponsorResults.length > 0) { - const topScore = sponsorResults[0].score; - // Get all 100% matches, or just the top match - const perfectMatches = sponsorResults.filter(r => r.score === 100); - const matchesToReport = perfectMatches.length >= 2 - ? perfectMatches.slice(0, 2) - : [sponsorResults[0]]; - - sponsorMatchScore = topScore; - sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)); - } + const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults); + sponsorMatchScore = summary.sponsorMatchScore; + sponsorMatchNames = summary.sponsorMatchNames ?? undefined; } // Update score and sponsor match in database diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts index d8c8d00..5ef0daa 100644 --- a/orchestrator/src/server/pipeline/sponsor-matching.test.ts +++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts @@ -11,6 +11,7 @@ import type { Job } from '../../shared/types.js'; // Mock the visa-sponsors module vi.mock('../services/visa-sponsors/index.js', () => ({ searchSponsors: vi.fn(), + calculateSponsorMatchSummary: vi.fn(), })); // Mock the scorer module @@ -115,6 +116,7 @@ const createMockJob = (overrides: Partial = {}): Job => ({ describe('Sponsor Match Calculation', () => { let searchSponsors: ReturnType; + let calculateSponsorMatchSummary: ReturnType; let scoreJobSuitability: ReturnType; let updateJob: ReturnType; let getUnscoredDiscoveredJobs: ReturnType; @@ -129,6 +131,7 @@ describe('Sponsor Match Calculation', () => { const jobsRepo = await import('../repositories/jobs.js'); searchSponsors = visaSponsors.searchSponsors as ReturnType; + calculateSponsorMatchSummary = visaSponsors.calculateSponsorMatchSummary as ReturnType; scoreJobSuitability = scorer.scoreJobSuitability as ReturnType; updateJob = jobsRepo.updateJob as ReturnType; getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType; @@ -138,6 +141,17 @@ describe('Sponsor Match Calculation', () => { scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' }); bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 }); updateJob.mockResolvedValue(undefined); + + calculateSponsorMatchSummary.mockImplementation((results: any[]) => { + if (results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null }; + const topScore = results[0].score; + const perfectMatches = results.filter((r: any) => r.score === 100); + const matchesToReport = perfectMatches.length >= 2 ? perfectMatches.slice(0, 2) : [results[0]]; + return { + sponsorMatchScore: topScore, + sponsorMatchNames: JSON.stringify(matchesToReport.map((r: any) => r.sponsor.organisationName)), + }; + }); }); afterEach(() => { diff --git a/orchestrator/src/server/services/visa-sponsors/index.test.ts b/orchestrator/src/server/services/visa-sponsors/index.test.ts new file mode 100644 index 0000000..93272a2 --- /dev/null +++ b/orchestrator/src/server/services/visa-sponsors/index.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { calculateSponsorMatchSummary } from './index.js'; +import type { VisaSponsorSearchResult } from '../../../shared/types.js'; + +describe('calculateSponsorMatchSummary', () => { + it('should return default values for empty results', () => { + const results: VisaSponsorSearchResult[] = []; + const summary = calculateSponsorMatchSummary(results); + + expect(summary.sponsorMatchScore).toBe(0); + expect(summary.sponsorMatchNames).toBeNull(); + }); + + it('should report the top match when it is not a perfect match', () => { + const results: VisaSponsorSearchResult[] = [ + { + score: 85, + sponsor: { organisationName: 'Tech Corp' } as any, + matchedName: 'tech corp' + }, + { + score: 60, + sponsor: { organisationName: 'Other Ltd' } as any, + matchedName: 'other' + } + ]; + + const summary = calculateSponsorMatchSummary(results); + + expect(summary.sponsorMatchScore).toBe(85); + expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Tech Corp'])); + }); + + it('should report a single perfect match', () => { + const results: VisaSponsorSearchResult[] = [ + { + score: 100, + sponsor: { organisationName: 'Exact Match Ltd' } as any, + matchedName: 'exact match' + }, + { + score: 90, + sponsor: { organisationName: 'Close Match' } as any, + matchedName: 'close' + } + ]; + + const summary = calculateSponsorMatchSummary(results); + + expect(summary.sponsorMatchScore).toBe(100); + expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Exact Match Ltd'])); + }); + + it('should report exactly two 100% matches when two or more exist', () => { + const results: VisaSponsorSearchResult[] = [ + { + score: 100, + sponsor: { organisationName: 'First PerfectMatch' } as any, + matchedName: 'match' + }, + { + score: 100, + sponsor: { organisationName: 'Second PerfectMatch' } as any, + matchedName: 'match' + }, + { + score: 100, + sponsor: { organisationName: 'Third PerfectMatch' } as any, + matchedName: 'match' + }, + { + score: 50, + sponsor: { organisationName: 'Common Co' } as any, + matchedName: 'common' + } + ]; + + const summary = calculateSponsorMatchSummary(results); + + expect(summary.sponsorMatchScore).toBe(100); + const names = JSON.parse(summary.sponsorMatchNames!); + expect(names).toHaveLength(2); + expect(names).toContain('First PerfectMatch'); + expect(names).toContain('Second PerfectMatch'); + expect(names).not.toContain('Third PerfectMatch'); + }); + + it('should only report the single top result if no 100% matches exist', () => { + const results: VisaSponsorSearchResult[] = [ + { + score: 99, + sponsor: { organisationName: 'Almost Perfect' } as any, + matchedName: 'almost' + }, + { + score: 98, + sponsor: { organisationName: 'Second Best' } as any, + matchedName: 'best' + } + ]; + + const summary = calculateSponsorMatchSummary(results); + + expect(summary.sponsorMatchScore).toBe(99); + expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Almost Perfect'])); + }); +}); diff --git a/orchestrator/src/server/services/visa-sponsors/index.ts b/orchestrator/src/server/services/visa-sponsors/index.ts index b01c5f1..963d887 100644 --- a/orchestrator/src/server/services/visa-sponsors/index.ts +++ b/orchestrator/src/server/services/visa-sponsors/index.ts @@ -57,20 +57,20 @@ let updateError: string | null = null; */ export function normalizeCompanyName(name: string): string { let normalized = name.toLowerCase().trim(); - + // Remove common punctuation and special chars normalized = normalized.replace(/[.,'"()[\]{}!?@#$%^&*+=|\\/<>:;`~]/g, ' '); - + // Remove suffixes for (const suffix of COMPANY_SUFFIXES) { // Word boundary matching const regex = new RegExp(`\\b${suffix}\\b`, 'gi'); normalized = normalized.replace(regex, ''); } - + // Collapse whitespace normalized = normalized.replace(/\s+/g, ' ').trim(); - + return normalized; } @@ -81,27 +81,27 @@ export function normalizeCompanyName(name: string): string { export function calculateSimilarity(str1: string, str2: string): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); - + if (s1 === s2) return 100; if (s1.length === 0 || s2.length === 0) return 0; - + // Check if one contains the other if (s1.includes(s2) || s2.includes(s1)) { const longerLen = Math.max(s1.length, s2.length); const shorterLen = Math.min(s1.length, s2.length); return Math.round((shorterLen / longerLen) * 100); } - + // Levenshtein distance const matrix: number[][] = []; - + for (let i = 0; i <= s1.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= s2.length; j++) { matrix[0][j] = j; } - + for (let i = 1; i <= s1.length; i++) { for (let j = 1; j <= s2.length; j++) { const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; @@ -112,10 +112,10 @@ export function calculateSimilarity(str1: string, str2: string): number { ); } } - + const distance = matrix[s1.length][s2.length]; const maxLen = Math.max(s1.length, s2.length); - + return Math.round(((maxLen - distance) / maxLen) * 100); } @@ -125,12 +125,12 @@ export function calculateSimilarity(str1: string, str2: string): number { export function parseCsv(content: string): VisaSponsor[] { const lines = content.split('\n'); const sponsors: VisaSponsor[] = []; - + // Skip header for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; - + // Parse CSV with proper quote handling const fields = parseCSVLine(line); if (fields.length >= 5) { @@ -143,7 +143,7 @@ export function parseCsv(content: string): VisaSponsor[] { }); } } - + return sponsors; } @@ -154,11 +154,11 @@ function parseCSVLine(line: string): string[] { const fields: string[] = []; let current = ''; let inQuotes = false; - + for (let i = 0; i < line.length; i++) { const char = line[i]; const nextChar = line[i + 1]; - + if (char === '"' && !inQuotes) { inQuotes = true; } else if (char === '"' && inQuotes) { @@ -176,7 +176,7 @@ function parseCSVLine(line: string): string[] { current += char; } } - + fields.push(current.trim()); return fields; } @@ -186,7 +186,7 @@ function parseCSVLine(line: string): string[] { */ function getCsvFiles(): string[] { if (!fs.existsSync(DATA_DIR)) return []; - + return fs.readdirSync(DATA_DIR) .filter(f => f.endsWith('.csv')) .sort() @@ -245,25 +245,25 @@ function cleanupOldCsvFiles(): void { */ async function extractCsvUrl(): Promise { const pageUrl = 'https://www.gov.uk/government/publications/register-of-licensed-sponsors-workers'; - + console.log('📄 Fetching gov.uk page to find CSV link...'); const response = await fetch(pageUrl); - + if (!response.ok) { throw new Error(`Failed to fetch gov.uk page: ${response.status} ${response.statusText}`); } - + const html = await response.text(); - + // Look for the Worker and Temporary Worker CSV link const csvMatch = html.match( /href="(https:\/\/assets\.publishing\.service\.gov\.uk\/media\/[^"]+Worker_and_Temporary_Worker\.csv)"/ ); - + if (!csvMatch) { throw new Error('Could not find Worker and Temporary Worker CSV link on gov.uk page'); } - + return csvMatch[1]; } @@ -274,52 +274,52 @@ export async function downloadLatestCsv(): Promise<{ success: boolean; message: if (isUpdating) { return { success: false, message: 'Update already in progress' }; } - + isUpdating = true; updateError = null; - + try { // Extract the CSV URL from the page const csvUrl = await extractCsvUrl(); console.log(`📥 Downloading CSV from: ${csvUrl}`); - + const response = await fetch(csvUrl); - + if (!response.ok) { throw new Error(`Failed to download CSV: ${response.status} ${response.statusText}`); } - + const csvContent = await response.text(); - + // Validate CSV has content const sponsors = parseCsv(csvContent); if (sponsors.length === 0) { throw new Error('Downloaded CSV appears to be empty or invalid'); } - + // Generate filename with date const dateStr = new Date().toISOString().split('T')[0]; const filename = `visa_sponsors_${dateStr}.csv`; const filepath = path.join(DATA_DIR, filename); - + // Save the CSV fs.writeFileSync(filepath, csvContent); - + // Update metadata writeMetadata({ lastUpdated: new Date().toISOString(), csvFile: filename, }); - + // Cleanup old files cleanupOldCsvFiles(); - + // Clear cache so next search loads new data sponsorsCache = null; cacheLoadedAt = null; - + console.log(`✅ Downloaded visa sponsor list: ${sponsors.length} sponsors`); - + return { success: true, message: `Successfully downloaded ${sponsors.length} sponsors`, @@ -345,17 +345,17 @@ export function loadSponsors(): VisaSponsor[] { return sponsorsCache; } } - + const metadata = readMetadata(); if (!metadata.csvFile) { return []; } - + const csvPath = path.join(DATA_DIR, metadata.csvFile); if (!fs.existsSync(csvPath)) { return []; } - + try { const content = fs.readFileSync(csvPath, 'utf-8'); sponsorsCache = parseCsv(content); @@ -375,26 +375,26 @@ export function searchSponsors( options: { limit?: number; minScore?: number } = {} ): VisaSponsorSearchResult[] { const { limit = 50, minScore = 30 } = options; - + const sponsors = loadSponsors(); if (sponsors.length === 0 || !query.trim()) { return []; } - + const normalizedQuery = normalizeCompanyName(query); const results: VisaSponsorSearchResult[] = []; const seen = new Set(); // Dedupe by org name - + for (const sponsor of sponsors) { // Skip if we've already seen this org name if (seen.has(sponsor.organisationName)) continue; seen.add(sponsor.organisationName); - + const normalizedSponsor = normalizeCompanyName(sponsor.organisationName); - + // Calculate similarity const score = calculateSimilarity(normalizedQuery, normalizedSponsor); - + if (score >= minScore) { results.push({ sponsor, @@ -403,20 +403,43 @@ export function searchSponsors( }); } } - + // Sort by score descending results.sort((a, b) => b.score - a.score); - + return results.slice(0, limit); } +/** + * Calculate match summary from search results + */ +export function calculateSponsorMatchSummary( + results: VisaSponsorSearchResult[] +): { sponsorMatchScore: number; sponsorMatchNames: string | null } { + if (results.length === 0) { + return { sponsorMatchScore: 0, sponsorMatchNames: null }; + } + + const topScore = results[0].score; + // Get all 100% matches, or just the top match + const perfectMatches = results.filter(r => r.score === 100); + const matchesToReport = perfectMatches.length >= 2 + ? perfectMatches.slice(0, 2) + : [results[0]]; + + return { + sponsorMatchScore: topScore, + sponsorMatchNames: JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)), + }; +} + /** * Get status of the visa sponsor service */ export function getStatus(): VisaSponsorStatus { const metadata = readMetadata(); const sponsors = loadSponsors(); - + return { lastUpdated: metadata.lastUpdated, csvPath: metadata.csvFile ? path.join(DATA_DIR, metadata.csvFile) : null, @@ -449,12 +472,12 @@ function calculateNextUpdateTime(hour = 2): Date { const now = new Date(); const next = new Date(now); next.setHours(hour, 0, 0, 0); - + // If we've passed the time today, schedule for tomorrow if (next <= now) { next.setDate(next.getDate() + 1); } - + return next; } @@ -472,12 +495,12 @@ function scheduleNextUpdate(hour = 2): void { if (scheduledTimer) { clearTimeout(scheduledTimer); } - + nextScheduledUpdateTime = calculateNextUpdateTime(hour); const delay = nextScheduledUpdateTime.getTime() - Date.now(); - + console.log(`⏰ Next visa sponsor update scheduled for: ${nextScheduledUpdateTime.toISOString()}`); - + scheduledTimer = setTimeout(async () => { console.log('🔄 Running scheduled visa sponsor update...'); await downloadLatestCsv(); @@ -510,7 +533,7 @@ export function stopScheduler(): void { */ export async function initialize(): Promise { const metadata = readMetadata(); - + if (!metadata.csvFile) { console.log('📥 No visa sponsor data found, downloading...'); await downloadLatestCsv(); @@ -518,7 +541,7 @@ export async function initialize(): Promise { const sponsors = loadSponsors(); console.log(`✅ Visa sponsor service initialized with ${sponsors.length} sponsors`); } - + // Start the scheduler for automatic daily updates at 2 AM startScheduler(2); } From 2d0d91cd2eba5f3a6b373d2607e114eff5eda1dc Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:32:35 +0000 Subject: [PATCH 17/76] Update orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../client/pages/settings/components/DisplaySettingsSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx index 564d0d9..88ba312 100644 --- a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx @@ -35,7 +35,7 @@ export const DisplaySettingsSection: React.FC = ({ id="showSponsorInfo" checked={isChecked} onCheckedChange={(checked) => { - setShowSponsorInfoDraft(checked === true ? true : false) + setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true) }} disabled={isLoading || isSaving} /> From 2f9845338a1c7fff8393e793d3d4ecf0ecf9e5a9 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:37:47 +0000 Subject: [PATCH 18/76] useSettings error handling --- .../src/client/hooks/useSettings.test.ts | 14 ++++++++ orchestrator/src/client/hooks/useSettings.ts | 32 ++++++++++++++++--- .../src/server/api/routes/test-utils.ts | 9 +++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts index e8aff35..6fba317 100644 --- a/orchestrator/src/client/hooks/useSettings.test.ts +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -63,4 +63,18 @@ describe('useSettings', () => { expect(result.current.settings).toEqual(updatedSettings); expect(result.current.showSponsorInfo).toBe(false); }); + + it('handles errors when fetching settings', async () => { + const mockError = new Error('Failed to fetch'); + (api.getSettings as any).mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.error).toEqual(mockError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.settings).toBeNull(); + }); }); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index cb8fae3..bf1c7cd 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -3,29 +3,41 @@ import type { AppSettings } from '../../shared/types'; import * as api from '../api'; let settingsCache: AppSettings | null = null; -let subscribers: Set<(settings: AppSettings) => void> = new Set(); +let settingsError: Error | null = null; +let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set(); let isFetching = false; export function useSettings() { const [settings, setSettings] = useState(settingsCache); + const [error, setError] = useState(settingsError); useEffect(() => { if (settingsCache) { setSettings(settingsCache); } + if (settingsError) { + setError(settingsError); + } - const handleUpdate = (newSettings: AppSettings) => { + const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => { setSettings(newSettings); + setError(newError); }; subscribers.add(handleUpdate); if (!settingsCache && !isFetching) { isFetching = true; + settingsError = null; api.getSettings() .then((data) => { settingsCache = data; - subscribers.forEach(sub => sub(data)); + settingsError = null; + subscribers.forEach(sub => sub(data, null)); + }) + .catch((err) => { + settingsError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(settingsCache, settingsError)); }) .finally(() => { isFetching = false; @@ -39,11 +51,19 @@ export function useSettings() { const refreshSettings = async () => { isFetching = true; + settingsError = null; + subscribers.forEach(sub => sub(settingsCache, null)); + try { const data = await api.getSettings(); settingsCache = data; - subscribers.forEach(sub => sub(data)); + settingsError = null; + subscribers.forEach(sub => sub(data, null)); return data; + } catch (err) { + settingsError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(settingsCache, settingsError)); + throw settingsError; } finally { isFetching = false; } @@ -51,7 +71,8 @@ export function useSettings() { return { settings, - isLoading: !settings && isFetching, + error, + isLoading: !settings && isFetching && !error, showSponsorInfo: settings?.showSponsorInfo ?? true, refreshSettings, }; @@ -60,6 +81,7 @@ export function useSettings() { /** @internal For testing only */ export function _resetSettingsCache() { settingsCache = null; + settingsError = null; isFetching = false; subscribers.clear(); } diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index ec6bd96..246f0aa 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -28,7 +28,7 @@ vi.mock('../../pipeline/index.js', () => { getPipelineStatus: vi.fn(() => ({ isRunning: false })), subscribeToProgress: vi.fn((listener: (data: unknown) => void) => { listener(progress); - return () => {}; + return () => { }; }), }; }); @@ -54,6 +54,13 @@ vi.mock('../../services/visa-sponsors/index.js', () => ({ searchSponsors: vi.fn(), getOrganizationDetails: vi.fn(), downloadLatestCsv: vi.fn(), + calculateSponsorMatchSummary: vi.fn((results) => { + if (!results || results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null }; + return { + sponsorMatchScore: results[0].score, + sponsorMatchNames: JSON.stringify(results.map((r: any) => r.sponsor.organisationName)) + }; + }), })); const originalEnv = { ...process.env }; From 3d692f2f8b0e1750e3d65fd03c5ee512d798daa1 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 02:03:29 +0000 Subject: [PATCH 19/76] initial commit into hardening --- .../src/server/services/scorer.test.ts | 241 ++++++++++++++++ orchestrator/src/server/services/scorer.ts | 140 +++++++--- orchestrator/src/shared/rxresume-schema.ts | 258 ++++++++++++++++++ 3 files changed, 602 insertions(+), 37 deletions(-) create mode 100644 orchestrator/src/server/services/scorer.test.ts create mode 100644 orchestrator/src/shared/rxresume-schema.ts diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts new file mode 100644 index 0000000..08caa61 --- /dev/null +++ b/orchestrator/src/server/services/scorer.test.ts @@ -0,0 +1,241 @@ +/** + * Tests for scorer.ts - focusing on robust JSON parsing from AI responses + */ + +import { describe, it, expect } from 'vitest'; +import { parseJsonFromContent } from './scorer.js'; + +describe('parseJsonFromContent', () => { + describe('valid JSON inputs', () => { + it('should parse clean JSON object', () => { + const input = '{"score": 85, "reason": "Great match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBe('Great match'); + }); + + it('should parse JSON with extra whitespace', () => { + const input = ' { "score" : 75 , "reason" : "Good fit" } '; + const result = parseJsonFromContent(input); + expect(result.score).toBe(75); + expect(result.reason).toBe('Good fit'); + }); + + it('should parse JSON with newlines', () => { + const input = `{ + "score": 90, + "reason": "Excellent match for the role" + }`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(90); + expect(result.reason).toBe('Excellent match for the role'); + }); + }); + + describe('markdown code fences', () => { + it('should strip ```json code fences', () => { + const input = '```json\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ```JSON code fences (uppercase)', () => { + const input = '```JSON\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ``` code fences without language specifier', () => { + const input = '```\n{"score": 70, "reason": "Decent"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + }); + + it('should handle nested code fence patterns', () => { + const input = 'Here is the score:\n```json\n{"score": 65, "reason": "Partial match"}\n```\nEnd.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(65); + }); + }); + + describe('surrounding text', () => { + it('should extract JSON from text before', () => { + const input = 'Based on my analysis, here is my evaluation: {"score": 55, "reason": "Limited match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(55); + }); + + it('should extract JSON from text after', () => { + const input = '{"score": 60, "reason": "Moderate match"} I hope this helps!'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(60); + }); + + it('should extract JSON from surrounding text on both sides', () => { + const input = 'Here is my response:\n\n{"score": 45, "reason": "Below average fit"}\n\nLet me know if you need more details.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(45); + }); + }); + + describe('common JSON formatting issues', () => { + it('should handle trailing comma before closing brace', () => { + const input = '{"score": 78, "reason": "Good skills",}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(78); + }); + + it('should handle single quotes instead of double quotes', () => { + const input = "{'score': 82, 'reason': 'Strong candidate'}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(82); + }); + + it('should handle unquoted keys', () => { + const input = '{score: 77, reason: "Reasonable match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(77); + }); + + it('should handle mixed issues (trailing comma, single quotes)', () => { + const input = "{'score': 68, 'reason': 'Average fit',}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(68); + }); + }); + + describe('decimal scores', () => { + it('should parse and round decimal scores', () => { + // parseJsonFromContent returns raw value for valid JSON; rounding only in regex fallback + const input = '{"score": 85.7, "reason": "Very good match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85.7); + }); + + it('should parse decimal scores in malformed text', () => { + const input = 'The score is score: 72.3, reason: "Above average"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + }); + }); + + describe('malformed responses - regex fallback', () => { + it('should extract score from completely malformed response', () => { + const input = 'I think the score should be score: 50 and the reason: "Average candidate"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + }); + + it('should extract score with equals sign syntax', () => { + const input = 'score = 88, reason = "Excellent match"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(88); + }); + + it('should handle reason with special characters', () => { + const input = '{"score": 73, "reason": "Good match! The candidate\'s skills align well."}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(73); + }); + + it('should provide default reason when only score is extractable', () => { + const input = 'I rate this candidate 85 out of 100 - score: 85'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle zero score', () => { + const input = '{"score": 0, "reason": "No match at all"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(0); + }); + + it('should handle score of 100', () => { + const input = '{"score": 100, "reason": "Perfect candidate"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(100); + }); + + it('should handle empty reason', () => { + const input = '{"score": 50, "reason": ""}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + expect(result.reason).toBe(''); + }); + + it('should handle multiline reason', () => { + const input = `{"score": 70, "reason": "Good skills match. Experience is a bit lacking."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + expect(result.reason).toContain('Good skills match'); + }); + + it('should handle unicode in reason', () => { + const input = '{"score": 80, "reason": "Great match ✓ for this role"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + }); + + describe('failure cases', () => { + it('should throw when no score can be extracted', () => { + const input = 'This is just plain text with no JSON or score.'; + expect(() => parseJsonFromContent(input)).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for empty input', () => { + expect(() => parseJsonFromContent('')).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for only whitespace', () => { + expect(() => parseJsonFromContent(' \n\t ')).toThrow('Unable to parse JSON from model response'); + }); + }); + + describe('real-world AI responses', () => { + it('should handle GPT-style verbose response', () => { + const input = `Based on my analysis of the job description and candidate profile, I have evaluated the fit: + +\`\`\`json +{ + "score": 72, + "reason": "Strong React and TypeScript skills match. However, the role requires 5+ years experience which the candidate may not have." +} +\`\`\` + +This score reflects the candidate's technical capabilities while accounting for the experience gap.`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + expect(result.reason).toContain('React and TypeScript'); + }); + + it('should handle Claude-style response with thinking', () => { + const input = `Let me evaluate this candidate against the job requirements. + +{"score": 83, "reason": "Excellent frontend skills with React and modern tooling. Good culture fit based on startup experience."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(83); + }); + + it('should handle response with JSON5-style comments', () => { + // Some models output JSON5-like syntax with comments + const input = `{ + "score": 67, // Good but not great + "reason": "Matches most requirements but lacks cloud experience" +}`; + // This will fail standard parse but regex should catch it + const result = parseJsonFromContent(input); + expect(result.score).toBe(67); + }); + + it('should handle response with extra properties', () => { + const input = '{"score": 79, "reason": "Good match", "confidence": "high", "breakdown": {"skills": 25, "experience": 20}}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(79); + expect(result.reason).toBe('Good match'); + }); + }); +}); diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 4c7e18d..909a802 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -29,9 +29,9 @@ export async function scoreJobSuitability( const overrideModelScorer = await getSetting('modelScorer'); // Precedence: Scorer-specific override > Global override > Env var > Default const model = overrideModelScorer || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; - + const prompt = buildScoringPrompt(job, profile); - + try { const response = await fetch(OPENROUTER_API_URL, { method: 'POST', @@ -47,19 +47,20 @@ export async function scoreJobSuitability( 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); + // Log raw response for debugging when issues occur + const parsed = parseJsonFromContent(content, job.id); return { score: Math.min(100, Math.max(0, parsed.score || 0)), reason: parsed.reason || 'No explanation provided', @@ -70,39 +71,98 @@ export async function scoreJobSuitability( } } -function parseJsonFromContent(content: string): { score?: number; reason?: string } { - const trimmed = content.trim(); - const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim(); - const candidate = withoutFences; +/** + * Robustly parse JSON from AI-generated content. + * Handles common AI quirks: markdown fences, extra text, trailing commas, etc. + */ +export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } { + const originalContent = content; + let candidate = content.trim(); + // Step 1: Remove markdown code fences (with or without language specifier) + candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim(); + + // Step 2: Try to extract JSON object if there's surrounding text + const jsonMatch = candidate.match(/\{[\s\S]*\}/); + if (jsonMatch) { + candidate = jsonMatch[0]; + } + + // Step 3: Try direct parse first try { return JSON.parse(candidate); } catch { - const firstBrace = candidate.indexOf('{'); - const lastBrace = candidate.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sliced = candidate.slice(firstBrace, lastBrace + 1); - return JSON.parse(sliced); - } - throw new Error('Unable to parse JSON from model response'); + // Continue with sanitization } + + // Step 4: Fix common JSON issues + let sanitized = candidate; + + // Remove JavaScript-style comments (// and /* */) + sanitized = sanitized.replace(/\/\/[^\n]*/g, ''); + sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Remove trailing commas before } or ] + sanitized = sanitized.replace(/,\s*([\]}])/g, '$1'); + + // Fix unquoted keys: word: -> "word": + // Be more careful - only match at start of object or after comma + sanitized = sanitized.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":'); + + // Fix single quotes to double quotes + sanitized = sanitized.replace(/'/g, '"'); + + // Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON) + // First, let's normalize the string - escape actual newlines inside strings + sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, (match) => { + if (match === '\n') return '\\n'; + if (match === '\r') return '\\r'; + if (match === '\t') return '\\t'; + return ''; + }); + + // Step 5: Try parsing the sanitized version + try { + return JSON.parse(sanitized); + } catch { + // Continue with more aggressive extraction + } + + // Step 6: Even more aggressive - try to rebuild a minimal valid JSON + // by extracting just the score and reason values + const scoreMatch = originalContent.match(/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i); + const reasonMatch = originalContent.match(/["']?reason["']?\s*[:=]\s*["']([^"'\n]+)["']/i) || + originalContent.match(/["']?reason["']?\s*[:=]\s*["']?(.*?)["']?\s*[,}\n]/is); + + if (scoreMatch) { + const score = Math.round(parseFloat(scoreMatch[1])); + const reason = reasonMatch ? reasonMatch[1].trim().replace(/[\x00-\x1F\x7F]/g, '') : 'Score extracted from malformed response'; + console.log(`⚠️ [Job ${jobId || 'unknown'}] Parsed score via regex fallback: ${score}`); + return { score, reason }; + } + + // Log the failure with full content for debugging + console.error(`❌ [Job ${jobId || 'unknown'}] Failed to parse AI response. Raw content (first 500 chars):`, + originalContent.substring(0, 500)); + console.error(` Sanitized content (first 500 chars):`, sanitized.substring(0, 500)); + + throw new Error('Unable to parse JSON from model response'); } function buildScoringPrompt(job: Job, profile: Record): string { - return ` -You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100. + return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100. -Consider: -- Skills match (technologies, frameworks, languages) -- Experience level match -- Location/remote work alignment -- Industry/domain fit -- Career growth potential +SCORING CRITERIA: +- Skills match (technologies, frameworks, languages): 0-30 points +- Experience level match: 0-25 points +- Location/remote work alignment: 0-15 points +- Industry/domain fit: 0-15 points +- Career growth potential: 0-15 points -Candidate Profile: +CANDIDATE PROFILE: ${JSON.stringify(profile, null, 2)} -Job Listing: +JOB LISTING: Title: ${job.title} Employer: ${job.employer} Location: ${job.location || 'Not specified'} @@ -110,33 +170,39 @@ Salary: ${job.salary || 'Not specified'} Degree Required: ${job.degreeRequired || 'Not specified'} Disciplines: ${job.disciplines || 'Not specified'} -Job Description: +JOB DESCRIPTION: ${job.jobDescription || 'No description available'} -Respond with JSON only (no code fences): { "score": <0-100>, "reason": "" } -`; +IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON. + +REQUIRED FORMAT (exactly this structure): +{"score": , "reason": "<1-2 sentence explanation>"} + +EXAMPLE VALID RESPONSE: +{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`; } + function mockScore(job: Job): SuitabilityResult { // Simple keyword-based scoring as fallback const jd = (job.jobDescription || '').toLowerCase(); const title = job.title.toLowerCase(); - + const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer']; const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager']; - + let score = 50; - + for (const kw of goodKeywords) { if (jd.includes(kw) || title.includes(kw)) score += 5; } - + for (const kw of badKeywords) { if (jd.includes(kw) || title.includes(kw)) score -= 10; } - + score = Math.min(100, Math.max(0, score)); - + return { score, reason: 'Scored using keyword matching (API key not configured)', @@ -160,6 +226,6 @@ export async function scoreAndRankJobs( }; }) ); - + return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore); } diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts new file mode 100644 index 0000000..086daa8 --- /dev/null +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -0,0 +1,258 @@ +import { z } from "zod"; + +/** + * Schema matching the JSON you pasted (the "visible"/"summary"/"date"/"href" format). + * This is intentionally permissive (passthrough) so small future additions won't break parsing. + */ + +export const hrefUrlSchema = z.object({ + href: z.string().default(""), + label: z.string().default(""), +}); + +export const pictureEffectsSchema = z.object({ + border: z.boolean().default(false), + hidden: z.boolean().default(false), + grayscale: z.boolean().default(false), +}); + +export const basicsPictureSchema = z.object({ + url: z.string().default(""), + size: z.number().default(120), + effects: pictureEffectsSchema, + aspectRatio: z.number().default(1), + borderRadius: z.number().default(0), +}); + +export const customFieldSchema = z + .object({ + id: z.string().optional(), + icon: z.string().optional(), + text: z.string().optional(), + }) + .passthrough(); + +export const basicsSchema = z + .object({ + url: hrefUrlSchema, + name: z.string(), + email: z.string().email().or(z.literal("")).default(""), + phone: z.string().default(""), + picture: basicsPictureSchema, + headline: z.string().default(""), + location: z.string().default(""), + customFields: z.array(customFieldSchema).default([]), + }) + .passthrough(); + +export const metadataCssSchema = z.object({ + value: z.string().default(""), + visible: z.boolean().default(false), +}); + +export const metadataPageOptionsSchema = z.object({ + breakLine: z.boolean().default(false), + pageNumbers: z.boolean().default(false), +}); + +export const metadataPageSchema = z.object({ + format: z.enum(["a4", "letter"]).default("a4"), + margin: z.number().default(34), + options: metadataPageOptionsSchema.default({ breakLine: false, pageNumbers: false }), +}); + +export const metadataThemeSchema = z.object({ + text: z.string().default("#000000"), + primary: z.string().default("#475569"), + background: z.string().default("#ffffff"), +}); + +/** + * Your "layout" is shaped like: + * [ + * [ + * [ "summary", "profiles", ... ], // main column ids + * [ "skills", "languages" ] // sidebar column ids + * ], + * ... + * ] + */ +export const metadataLayoutSchema = z.array( + z.tuple([z.array(z.string()), z.array(z.string())]) +); + +export const metadataTypographySchema = z + .object({ + font: z.object({ + size: z.number().default(13), + family: z.string().default("IBM Plex Sans"), + subset: z.string().default("latin"), + variants: z.array(z.string()).default(["regular"]), + }), + hideIcons: z.boolean().default(false), + lineHeight: z.number().default(1.75), + underlineLinks: z.boolean().default(true), + }) + .passthrough(); + +export const metadataSchema = z + .object({ + css: metadataCssSchema, + page: metadataPageSchema, + notes: z.string().default(""), + theme: metadataThemeSchema, + layout: metadataLayoutSchema.default([]), + template: z.string().default("onyx"), + typography: metadataTypographySchema, + }) + .passthrough(); + +/** Common section container used by most sections in your JSON */ +export const baseSectionSchema = z + .object({ + id: z.string(), + name: z.string(), + columns: z.number().default(1), + visible: z.boolean().default(true), + separateLinks: z.boolean().default(true), + items: z.array(z.unknown()).default([]), + }) + .passthrough(); + +/** Item schemas (based on the items you included) */ +export const profileItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + icon: z.string().default(""), + network: z.string(), + visible: z.boolean().default(true), + username: z.string().default(""), + }) + .passthrough(); + +export const skillItemSchema = z + .object({ + id: z.string(), + name: z.string(), + level: z.number().default(0), + visible: z.boolean().default(true), + keywords: z.array(z.string()).default([]), + description: z.string().default(""), + }) + .passthrough(); + +export const projectItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + date: z.string().default(""), + name: z.string(), + summary: z.string().default(""), // HTML string in your data + visible: z.boolean().default(true), + keywords: z.array(z.string()).default([]), + description: z.string().default(""), + }) + .passthrough(); + +export const educationItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + area: z.string().default(""), + date: z.string().default(""), + score: z.string().default(""), + summary: z.string().default(""), // HTML string + visible: z.boolean().default(true), + studyType: z.string().default(""), + institution: z.string().default(""), + }) + .passthrough(); + +export const experienceItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + date: z.string().default(""), + company: z.string(), + summary: z.string().default(""), // HTML string + visible: z.boolean().default(true), + location: z.string().default(""), + position: z.string().default(""), + }) + .passthrough(); + +/** Section schemas with typed items */ +export const profilesSectionSchema = baseSectionSchema.extend({ + items: z.array(profileItemSchema).default([]), +}); + +export const skillsSectionSchema = baseSectionSchema.extend({ + items: z.array(skillItemSchema).default([]), +}); + +export const projectsSectionSchema = baseSectionSchema.extend({ + items: z.array(projectItemSchema).default([]), +}); + +export const educationSectionSchema = baseSectionSchema.extend({ + items: z.array(educationItemSchema).default([]), +}); + +export const experienceSectionSchema = baseSectionSchema.extend({ + items: z.array(experienceItemSchema).default([]), +}); + +/** + * Your "summary" section is not an items array; it carries "content". + * Keep it separate. + */ +export const summarySectionSchema = z + .object({ + id: z.string(), + name: z.string(), + columns: z.number().default(1), + content: z.string().default(""), // HTML string + visible: z.boolean().default(true), + separateLinks: z.boolean().default(true), + }) + .passthrough(); + +/** Empty-ish sections (you have them as items: []) */ +export const emptyItemsSectionSchema = baseSectionSchema.extend({ + items: z.array(z.unknown()).default([]), +}); + +/** + * Your "sections" object contains a fixed set of keys, plus `custom: {}`. + * `custom` is an object with no guaranteed structure in your sample, so passthrough. + */ +export const sectionsSchema = z + .object({ + awards: emptyItemsSectionSchema, + custom: z.object({}).passthrough().default({}), + skills: skillsSectionSchema, + summary: summarySectionSchema, + profiles: profilesSectionSchema, + projects: projectsSectionSchema, + education: educationSectionSchema, + interests: emptyItemsSectionSchema, + languages: emptyItemsSectionSchema, + volunteer: emptyItemsSectionSchema, + experience: experienceSectionSchema, + references: emptyItemsSectionSchema, + publications: emptyItemsSectionSchema, + certifications: emptyItemsSectionSchema, + }) + .passthrough(); + +/** Top-level schema matching what you pasted */ +export const myResumeJsonSchema = z + .object({ + basics: basicsSchema, + metadata: metadataSchema, + sections: sectionsSchema, + }) + .passthrough(); + +export type MyResumeJson = z.infer; From c1bada9a4965882d4df6f22112c45605da6f106e Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 02:10:12 +0000 Subject: [PATCH 20/76] add retry --- orchestrator/src/server/services/scorer.ts | 98 ++++++++++++++-------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 909a802..e5714c7 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -14,6 +14,7 @@ interface SuitabilityResult { /** * Score a job's suitability based on profile and job description. + * Includes retry logic for when AI returns garbage responses. */ export async function scoreJobSuitability( job: Job, @@ -32,43 +33,72 @@ export async function scoreJobSuitability( const prompt = buildScoringPrompt(job, profile); - 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' }, - }), - }); + const MAX_RETRIES = 2; + let lastError: Error | null = null; - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + console.log(`🔄 [Job ${job.id}] Retry attempt ${attempt}/${MAX_RETRIES}...`); + // Small delay before retry + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + + 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'); + } + + // Try to parse the response + const parsed = parseJsonFromContent(content, job.id); + + // Validate we got a reasonable response + if (typeof parsed.score !== 'number' || isNaN(parsed.score)) { + throw new Error('Parsed response has no valid score'); + } + + return { + score: Math.min(100, Math.max(0, Math.round(parsed.score))), + reason: parsed.reason || 'No explanation provided', + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Only retry on parsing errors, not on network/API errors + if (lastError.message.includes('Unable to parse JSON') || + lastError.message.includes('Parsed response has no valid score')) { + console.warn(`⚠️ [Job ${job.id}] Attempt ${attempt + 1} failed: ${lastError.message}`); + continue; // Try again + } + + // For other errors, don't retry + break; } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - - if (!content) { - throw new Error('No content in response'); - } - - // Log raw response for debugging when issues occur - const parsed = parseJsonFromContent(content, job.id); - return { - score: Math.min(100, Math.max(0, parsed.score || 0)), - reason: parsed.reason || 'No explanation provided', - }; - } catch (error) { - console.error('Failed to score job:', error); - return mockScore(job); } + + console.error(`❌ [Job ${job.id}] All ${MAX_RETRIES + 1} attempts failed, using mock scoring. Last error:`, lastError?.message); + return mockScore(job); } /** From edfad499a60705e602de4647c3476cc1dbaef666 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:11:16 +0000 Subject: [PATCH 21/76] better default model, better comment --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 7960390..c304924 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,16 @@ # OpenRouter API for AI scoring and summaries # Get your key at: https://openrouter.ai/keys OPENROUTER_API_KEY=your_openrouter_api_key_here -MODEL=openai/gpt-4o-mini +MODEL=google/gemini-3-flash-preview # RXResume credentials for PDF generation # Create an account at: https://v4.rxresu.me RXRESUME_EMAIL=your_email@example.com RXRESUME_PASSWORD=your_password_here -# Optional: Basic Auth for write access (read-only without auth) +# Optional: Basic Auth for write access +# the app is fully unauthenticated if this isn't set, which is the default # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. -# Browsing remains public and read-only. BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= From 5409faaf5fc556d48c3677e40223dafea026d460 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:26:15 +0000 Subject: [PATCH 22/76] use structured responses for openrouter calls --- orchestrator/src/server/services/manualJob.ts | 104 ++++----- .../src/server/services/openrouter.test.ts | 198 ++++++++++++++++++ .../src/server/services/openrouter.ts | 147 +++++++++++++ .../src/server/services/projectSelection.ts | 101 ++++----- orchestrator/src/server/services/scorer.ts | 113 ++++------ orchestrator/src/server/services/summary.ts | 136 ++++++------ 6 files changed, 572 insertions(+), 227 deletions(-) create mode 100644 orchestrator/src/server/services/openrouter.test.ts create mode 100644 orchestrator/src/server/services/openrouter.ts diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index f2cac94..d7e2e65 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -4,18 +4,57 @@ import { getSetting } from '../repositories/settings.js'; import type { ManualJobDraft } from '../../shared/types.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface ManualJobInferenceResult { job: ManualJobDraft; warning?: string | null; } -export async function inferManualJobDetails(jobDescription: string): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; +/** Raw response type from the API (all fields are strings) */ +interface ManualJobApiResponse { + title: string; + employer: string; + location: string; + salary: string; + deadline: string; + jobUrl: string; + applicationLink: string; + jobType: string; + jobLevel: string; + jobFunction: string; + disciplines: string; + degreeRequired: string; + starting: string; +} - if (!apiKey) { +/** JSON schema for manual job extraction response */ +const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = { + name: 'manual_job_details', + schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Job title' }, + employer: { type: 'string', description: 'Company/employer name' }, + location: { type: 'string', description: 'Job location' }, + salary: { type: 'string', description: 'Salary information' }, + deadline: { type: 'string', description: 'Application deadline' }, + jobUrl: { type: 'string', description: 'URL of the job listing' }, + applicationLink: { type: 'string', description: 'Direct application URL' }, + jobType: { type: 'string', description: 'Employment type (full-time, part-time, etc.)' }, + jobLevel: { type: 'string', description: 'Seniority level (entry, mid, senior, etc.)' }, + jobFunction: { type: 'string', description: 'Job function/category' }, + disciplines: { type: 'string', description: 'Required disciplines or fields' }, + degreeRequired: { type: 'string', description: 'Required degree or education' }, + starting: { type: 'string', description: 'Start date information' }, + }, + required: ['title', 'employer', 'location', 'salary', 'deadline', 'jobUrl', 'applicationLink', 'jobType', 'jobLevel', 'jobFunction', 'disciplines', 'degreeRequired', 'starting'], + additionalProperties: false, + }, +}; + +export async function inferManualJobDetails(jobDescription: string): Promise { + if (!process.env.OPENROUTER_API_KEY) { return { job: {}, warning: 'OPENROUTER_API_KEY not set. Fill details manually.', @@ -26,41 +65,21 @@ export async function inferManualJobDetails(jobDescription: string): Promise({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: MANUAL_JOB_SCHEMA, + }); - 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); + if (!result.success) { + console.warn('Manual job inference failed:', result.error); return { job: {}, warning: 'AI inference failed. Fill details manually.', }; } + + return { job: normalizeDraft(result.data) }; } function buildInferencePrompt(jd: string): string { @@ -106,23 +125,6 @@ OUTPUT FORMAT (JSON ONLY): `.trim(); } -function parseJsonFromContent(content: string): Record { - 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): ManualJobDraft { const fields: Array = [ 'title', diff --git a/orchestrator/src/server/services/openrouter.test.ts b/orchestrator/src/server/services/openrouter.test.ts new file mode 100644 index 0000000..72ec8d4 --- /dev/null +++ b/orchestrator/src/server/services/openrouter.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for the shared OpenRouter API helper. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { callOpenRouter, parseJsonContent, type JsonSchemaDefinition } from './openrouter.js'; + +// Mock fetch globally +const originalFetch = global.fetch; + +const testSchema: JsonSchemaDefinition = { + name: 'test_schema', + schema: { + type: 'object', + properties: { + value: { type: 'string', description: 'A test value' }, + count: { type: 'integer', description: 'A test count' }, + }, + required: ['value', 'count'], + additionalProperties: false, + }, +}; + +describe('callOpenRouter', () => { + beforeEach(() => { + process.env.OPENROUTER_API_KEY = 'test-api-key'; + global.fetch = vi.fn(); + }); + + afterEach(() => { + delete process.env.OPENROUTER_API_KEY; + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('should return error when API key is not set', async () => { + delete process.env.OPENROUTER_API_KEY; + + const result = await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('API_KEY'); + } + }); + + it('should return parsed data on successful response', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: JSON.stringify({ value: 'hello', count: 42 }) } }], + }), + } as Response); + + const result = await callOpenRouter<{ value: string; count: number }>({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('hello'); + expect(result.data.count).toBe(42); + } + }); + + it('should handle API errors gracefully', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const result = await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('500'); + } + }); + + it('should handle empty response content', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '' } }], + }), + } as Response); + + const result = await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('No content'); + } + }); + + it('should include json_schema in request body', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"value": "test", "count": 1}' } }], + }), + } as Response); + + await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test prompt' }], + jsonSchema: testSchema, + }); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]?.body as string); + + expect(body.response_format.type).toBe('json_schema'); + expect(body.response_format.json_schema.name).toBe('test_schema'); + expect(body.response_format.json_schema.strict).toBe(true); + }); + + it('should retry on parsing failures when maxRetries is set', async () => { + let callCount = 0; + vi.mocked(global.fetch).mockImplementation(async () => { + callCount++; + if (callCount < 3) { + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: 'invalid json' } }], + }), + } as Response; + } + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"value": "success", "count": 3}' } }], + }), + } as Response; + }); + + // Suppress console output during test + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + + const result = await callOpenRouter<{ value: string; count: number }>({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + maxRetries: 2, + retryDelayMs: 10, // Fast retries for tests + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('success'); + } + expect(callCount).toBe(3); + }); +}); + +describe('parseJsonContent', () => { + it('should parse clean JSON', () => { + const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}'); + expect(result.foo).toBe('bar'); + }); + + it('should handle markdown code fences', () => { + const result = parseJsonContent<{ foo: string }>('```json\n{"foo": "bar"}\n```'); + expect(result.foo).toBe('bar'); + }); + + it('should handle json without language specifier', () => { + const result = parseJsonContent<{ foo: string }>('```\n{"foo": "bar"}\n```'); + expect(result.foo).toBe('bar'); + }); + + it('should extract JSON from surrounding text', () => { + const result = parseJsonContent<{ foo: string }>('Here is the result: {"foo": "bar"} as requested.'); + expect(result.foo).toBe('bar'); + }); + + it('should throw on completely invalid content', () => { + vi.spyOn(console, 'error').mockImplementation(() => { }); + expect(() => parseJsonContent('not json at all')).toThrow(); + }); +}); diff --git a/orchestrator/src/server/services/openrouter.ts b/orchestrator/src/server/services/openrouter.ts new file mode 100644 index 0000000..328ccfc --- /dev/null +++ b/orchestrator/src/server/services/openrouter.ts @@ -0,0 +1,147 @@ +/** + * Shared OpenRouter API helper for structured JSON responses. + */ + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +export interface JsonSchemaDefinition { + name: string; + schema: { + type: 'object'; + properties: Record; + required: string[]; + additionalProperties: boolean; + }; +} + +export interface OpenRouterRequestOptions { + /** The model to use (e.g., 'google/gemini-3-flash-preview') */ + model: string; + /** The prompt messages to send */ + messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>; + /** JSON schema for structured output */ + jsonSchema: JsonSchemaDefinition; + /** Number of retries on parsing failures (default: 0) */ + maxRetries?: number; + /** Delay between retries in ms (default: 500) */ + retryDelayMs?: number; + /** Job ID for logging purposes */ + jobId?: string; +} + +export interface OpenRouterResult { + success: true; + data: T; +} + +export interface OpenRouterError { + success: false; + error: string; +} + +export type OpenRouterResponse = OpenRouterResult | OpenRouterError; + +/** + * Call OpenRouter API with structured JSON output. + * + * @returns Parsed JSON response matching the schema, or an error object + */ +export async function callOpenRouter( + options: OpenRouterRequestOptions +): Promise> { + const apiKey = process.env.OPENROUTER_API_KEY; + + if (!apiKey) { + return { success: false, error: 'OPENROUTER_API_KEY not configured' }; + } + + const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + console.log(`🔄 [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`); + await sleep(retryDelayMs * attempt); + } + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'JobOps', + 'X-Title': 'JobOpsOrchestrator', + }, + body: JSON.stringify({ + model, + messages, + response_format: { + type: 'json_schema', + json_schema: { + name: jsonSchema.name, + strict: true, + schema: jsonSchema.schema, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`OpenRouter API error: ${response.status}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('No content in response'); + } + + // Parse JSON - structured outputs should always return valid JSON + const parsed = parseJsonContent(content, jobId); + + return { success: true, data: parsed }; + + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + // Only retry on parsing errors + if (attempt < maxRetries && message.includes('parse')) { + console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed: ${message}`); + continue; + } + + return { success: false, error: message }; + } + } + + return { success: false, error: 'All retry attempts failed' }; +} + +/** + * Parse JSON content from OpenRouter response. + * Handles common AI quirks like markdown code fences. + */ +export function parseJsonContent(content: string, jobId?: string): T { + let candidate = content.trim(); + + // Remove markdown code fences if present + candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim(); + + // Try to extract JSON object if there's surrounding text + const jsonMatch = candidate.match(/\{[\s\S]*\}/); + if (jsonMatch) { + candidate = jsonMatch[0]; + } + + try { + return JSON.parse(candidate) as T; + } catch (error) { + console.error(`❌ [${jobId ?? 'unknown'}] Failed to parse JSON:`, candidate.substring(0, 200)); + throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'unknown'}`); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index 1fd405f..a90530b 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -1,8 +1,27 @@ +/** + * Service for AI-powered project selection for resumes. + */ + import { getSetting } from '../repositories/settings.js'; - import type { ResumeProjectSelectionItem } from './resumeProjects.js'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +/** JSON schema for project selection response */ +const PROJECT_SELECTION_SCHEMA: JsonSchemaDefinition = { + name: 'project_selection', + schema: { + type: 'object', + properties: { + selectedProjectIds: { + type: 'array', + items: { type: 'string' }, + description: 'List of project IDs to include on the resume', + }, + }, + required: ['selectedProjectIds'], + additionalProperties: false, + }, +}; export async function pickProjectIdsForJob(args: { jobDescription: string; @@ -15,8 +34,7 @@ export async function pickProjectIdsForJob(args: { const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id)); if (eligibleIds.size === 0) return []; - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } @@ -31,53 +49,39 @@ export async function pickProjectIdsForJob(args: { desiredCount, }); - 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' }, - }), - }); + const result = await callOpenRouter<{ selectedProjectIds: string[] }>({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: PROJECT_SELECTION_SCHEMA, + }); - 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 = JSON.parse(content) as any; - const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : []; - const unique: string[] = []; - const seen = new Set(); - for (const id of selectedProjectIds) { - if (typeof id !== 'string') continue; - const trimmed = id.trim(); - if (!trimmed) continue; - if (!eligibleIds.has(trimmed)) continue; - if (seen.has(trimmed)) continue; - seen.add(trimmed); - unique.push(trimmed); - if (unique.length >= desiredCount) break; - } - - if (unique.length === 0) { - return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); - } - - return unique; - } catch { + if (!result.success) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } + + const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds) + ? result.data.selectedProjectIds + : []; + + // Validate and dedupe the returned IDs + const unique: string[] = []; + const seen = new Set(); + for (const id of selectedProjectIds) { + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (!trimmed) continue; + if (!eligibleIds.has(trimmed)) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + unique.push(trimmed); + if (unique.length >= desiredCount) break; + } + + if (unique.length === 0) { + return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); + } + + return unique; } function buildProjectSelectionPrompt(args: { @@ -167,4 +171,3 @@ function truncate(input: string, maxChars: number): string { if (input.length <= maxChars) return input; return `${input.slice(0, maxChars - 1).trimEnd()}…`; } - diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index e5714c7..ea46c79 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -4,14 +4,33 @@ import type { Job } from '../../shared/types.js'; import { getSetting } from '../repositories/settings.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; interface SuitabilityResult { score: number; // 0-100 reason: string; // Explanation } +/** JSON schema for suitability scoring response */ +const SCORING_SCHEMA: JsonSchemaDefinition = { + name: 'job_suitability_score', + schema: { + type: 'object', + properties: { + score: { + type: 'integer', + description: 'Suitability score from 0 to 100', + }, + reason: { + type: 'string', + description: 'Brief 1-2 sentence explanation of the score', + }, + }, + required: ['score', 'reason'], + additionalProperties: false, + }, +}; + /** * Score a job's suitability based on profile and job description. * Includes retry logic for when AI returns garbage responses. @@ -20,8 +39,7 @@ export async function scoreJobSuitability( job: Job, profile: Record ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring'); return mockScore(job); } @@ -33,77 +51,38 @@ export async function scoreJobSuitability( const prompt = buildScoringPrompt(job, profile); - const MAX_RETRIES = 2; - let lastError: Error | null = null; + const result = await callOpenRouter<{ score: number; reason: string }>({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: SCORING_SCHEMA, + maxRetries: 2, + jobId: job.id, + }); - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - if (attempt > 0) { - console.log(`🔄 [Job ${job.id}] Retry attempt ${attempt}/${MAX_RETRIES}...`); - // Small delay before retry - await new Promise(resolve => setTimeout(resolve, 500 * attempt)); - } - - 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'); - } - - // Try to parse the response - const parsed = parseJsonFromContent(content, job.id); - - // Validate we got a reasonable response - if (typeof parsed.score !== 'number' || isNaN(parsed.score)) { - throw new Error('Parsed response has no valid score'); - } - - return { - score: Math.min(100, Math.max(0, Math.round(parsed.score))), - reason: parsed.reason || 'No explanation provided', - }; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - // Only retry on parsing errors, not on network/API errors - if (lastError.message.includes('Unable to parse JSON') || - lastError.message.includes('Parsed response has no valid score')) { - console.warn(`⚠️ [Job ${job.id}] Attempt ${attempt + 1} failed: ${lastError.message}`); - continue; // Try again - } - - // For other errors, don't retry - break; - } + if (!result.success) { + console.error(`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`); + return mockScore(job); } - console.error(`❌ [Job ${job.id}] All ${MAX_RETRIES + 1} attempts failed, using mock scoring. Last error:`, lastError?.message); - return mockScore(job); + const { score, reason } = result.data; + + // Validate we got a reasonable response + if (typeof score !== 'number' || isNaN(score)) { + console.error(`❌ [Job ${job.id}] Invalid score in response, using mock scoring`); + return mockScore(job); + } + + return { + score: Math.min(100, Math.max(0, Math.round(score))), + reason: reason || 'No explanation provided', + }; } /** * Robustly parse JSON from AI-generated content. * Handles common AI quirks: markdown fences, extra text, trailing commas, etc. + * + * @deprecated Use callOpenRouter with structured outputs instead. Kept for backwards compatibility with tests. */ export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } { const originalContent = content; diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index fc70590..03fde39 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -3,13 +3,12 @@ */ import { getSetting } from '../repositories/settings.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface TailoredData { summary: string; headline: string; - skills: any[]; + skills: Array<{ name: string; keywords: string[] }>; } export interface TailoringResult { @@ -18,6 +17,46 @@ export interface TailoringResult { error?: string; } +/** JSON schema for resume tailoring response */ +const TAILORING_SCHEMA: JsonSchemaDefinition = { + name: 'resume_tailoring', + schema: { + type: 'object', + properties: { + headline: { + type: 'string', + description: 'Job title headline matching the JD exactly', + }, + summary: { + type: 'string', + description: 'Tailored resume summary paragraph', + }, + skills: { + type: 'array', + description: 'Skills sections with keywords tailored to the job', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Skill category name (e.g., Frontend, Backend)', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: 'List of skills/technologies in this category', + }, + }, + required: ['name', 'keywords'], + additionalProperties: false, + }, + }, + }, + required: ['headline', 'summary', 'skills'], + additionalProperties: false, + }, +}; + /** * Generate tailored resume content (summary, headline, skills) for a job. */ @@ -25,65 +64,42 @@ export async function generateTailoring( jobDescription: string, profile: Record ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring'); return { success: false, error: 'API key not configured' }; } - + const overrideModel = await getSetting('model'); const overrideModelTailoring = await getSetting('modelTailoring'); // Precedence: Tailoring-specific override > Global override > Env var > Default const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildTailoringPrompt(profile, 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 = JSON.parse(content); - - // Basic validation - if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) { - console.warn('⚠️ AI response missing required fields:', parsed); - } - return { - success: true, - data: { - summary: sanitizeText(parsed.summary || ''), - headline: sanitizeText(parsed.headline || ''), - skills: parsed.skills || [] - } - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { success: false, error: message }; + const result = await callOpenRouter({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: TAILORING_SCHEMA, + }); + + if (!result.success) { + return { success: false, error: result.error }; } + + const { summary, headline, skills } = result.data; + + // Basic validation + if (!summary || !headline || !Array.isArray(skills)) { + console.warn('⚠️ AI response missing required fields:', result.data); + } + + return { + success: true, + data: { + summary: sanitizeText(summary || ''), + headline: sanitizeText(headline || ''), + skills: skills || [] + } + }; } /** @@ -112,14 +128,14 @@ function buildTailoringPrompt(profile: Record, jd: string): str }, skills: (profile as any).sections?.skills || (profile as any).skills, projects: (profile as any).sections?.projects?.items?.map((p: any) => ({ - name: p.name, - description: p.description, - keywords: p.keywords + name: p.name, + description: p.description, + keywords: p.keywords })), experience: (profile as any).sections?.experience?.items?.map((e: any) => ({ - company: e.company, - position: e.position, - summary: e.summary + company: e.company, + position: e.position, + summary: e.summary })) }; @@ -127,8 +143,8 @@ function buildTailoringPrompt(profile: Record, jd: string): str You are an expert resume writer tailoring a profile for a specific job application. You must return a JSON object with three fields: "headline", "summary", and "skills". -JOB DESCRIPTION: -${jd.slice(0, 3000)} ... (truncated if too long) +JOB DESCRIPTION (JD): +${jd} MY PROFILE: ${JSON.stringify(relevantProfile, null, 2)} From ae5aa53b99a35811ccd29a86cca18b5a5b0af9ef Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:30:34 +0000 Subject: [PATCH 23/76] manual job type issue fix --- orchestrator/src/server/services/manualJob.ts | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index d7e2e65..7ceb079 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -125,41 +125,23 @@ OUTPUT FORMAT (JSON ONLY): `.trim(); } -function normalizeDraft(parsed: Record): ManualJobDraft { - const fields: Array = [ - 'title', - 'employer', - 'location', - 'salary', - 'deadline', - 'jobUrl', - 'applicationLink', - 'jobType', - 'jobLevel', - 'jobFunction', - 'disciplines', - 'degreeRequired', - 'starting', - ]; - +function normalizeDraft(parsed: ManualJobApiResponse): ManualJobDraft { const out: ManualJobDraft = {}; - for (const field of fields) { - const value = toCleanString(parsed[field]); - if (value) out[field] = value; - } + // Map each field, only including non-empty strings + if (parsed.title?.trim()) out.title = parsed.title.trim(); + if (parsed.employer?.trim()) out.employer = parsed.employer.trim(); + if (parsed.location?.trim()) out.location = parsed.location.trim(); + if (parsed.salary?.trim()) out.salary = parsed.salary.trim(); + if (parsed.deadline?.trim()) out.deadline = parsed.deadline.trim(); + if (parsed.jobUrl?.trim()) out.jobUrl = parsed.jobUrl.trim(); + if (parsed.applicationLink?.trim()) out.applicationLink = parsed.applicationLink.trim(); + if (parsed.jobType?.trim()) out.jobType = parsed.jobType.trim(); + if (parsed.jobLevel?.trim()) out.jobLevel = parsed.jobLevel.trim(); + if (parsed.jobFunction?.trim()) out.jobFunction = parsed.jobFunction.trim(); + if (parsed.disciplines?.trim()) out.disciplines = parsed.disciplines.trim(); + if (parsed.degreeRequired?.trim()) out.degreeRequired = parsed.degreeRequired.trim(); + if (parsed.starting?.trim()) out.starting = parsed.starting.trim(); 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; -} From 7cc5017e56ea2a13dcabfca84ace7a39d41958a0 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:36:11 +0000 Subject: [PATCH 24/76] max project limit set to below warning threshold --- orchestrator/src/server/services/resumeProjects.test.ts | 5 +++-- orchestrator/src/server/services/resumeProjects.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/orchestrator/src/server/services/resumeProjects.test.ts b/orchestrator/src/server/services/resumeProjects.test.ts index 8ca4d94..a43a891 100644 --- a/orchestrator/src/server/services/resumeProjects.test.ts +++ b/orchestrator/src/server/services/resumeProjects.test.ts @@ -66,7 +66,7 @@ describe('Resume Projects Logic', () => { }); it('should ensure maxProjects is at least len(locked)', () => { - const input = { + const input = { maxProjects: 1, // Too small lockedProjectIds: ['a', 'b'], aiSelectableProjectIds: [] @@ -105,6 +105,7 @@ describe('Resume Projects Logic', () => { // p1 is visible in base, so it should be locked by default expect(result.resumeProjects.lockedProjectIds).toEqual(['p1']); expect(result.resumeProjects.aiSelectableProjectIds).toEqual(['p2', 'p3']); + expect(result.resumeProjects.maxProjects).toBe(3); }); it('should apply valid overrides', () => { @@ -126,7 +127,7 @@ describe('Resume Projects Logic', () => { }); it('should handle invalid overrides by falling back to defaults', () => { - const result = rp.resolveResumeProjectsSettings({ + const result = rp.resolveResumeProjectsSettings({ catalog: mockCatalog, overrideRaw: '{"broken json' }); diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts index e97bad3..1d592c7 100644 --- a/orchestrator/src/server/services/resumeProjects.ts +++ b/orchestrator/src/server/services/resumeProjects.ts @@ -58,7 +58,7 @@ export function buildDefaultResumeProjectsSettings( .filter((id) => !lockedSet.has(id)); const total = catalog.length; - const preferredMax = Math.max(lockedProjectIds.length, 4); + const preferredMax = Math.max(lockedProjectIds.length, 3); const maxProjects = total === 0 ? 0 : Math.min(total, preferredMax); return normalizeResumeProjectsSettings( From 164256326fbf9fec6e5ceba6be16a113dd6f5142 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 13:17:09 +0000 Subject: [PATCH 25/76] Ready panel can edit now --- .../src/client/components/ReadyPanel.tsx | 52 +++++++++++++++---- .../discovered-panel/TailorMode.tsx | 13 +++-- .../pages/orchestrator/JobDetailPanel.tsx | 11 ---- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index acc2ade..580cb63 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -3,6 +3,8 @@ * * Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied. * The PDF is the primary artifact, represented abstractly through an Application Kit summary. + * + * Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,14 +44,15 @@ import { import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils"; import * as api from "../api"; import { FitAssessment, JobHeader, TailoredSummary } from "."; +import { TailorMode } from "./discovered-panel/TailorMode"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; +type PanelMode = "ready" | "tailor"; + interface ReadyPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; - onEditTailoring: () => void; - onEditDescription: () => void; } const safeFilenamePart = (value: string | null | undefined) => @@ -59,9 +62,8 @@ export const ReadyPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - onEditTailoring, - onEditDescription, }) => { + const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); const [catalog, setCatalog] = useState([]); @@ -77,6 +79,11 @@ export const ReadyPanel: React.FC = ({ api.getProfileProjects().then(setCatalog).catch(console.error); }, []); + // Reset mode when job changes + useEffect(() => { + setMode("ready"); + }, [job?.id]); + // Compute derived values const pdfHref = job ? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}` @@ -198,6 +205,23 @@ export const ReadyPanel: React.FC = ({ } }, [job]); + // Handler for regenerating PDF after tailoring edits + const handleTailorFinalize = useCallback(async () => { + if (!job) return; + try { + setIsRegenerating(true); + await api.generateJobPdf(job.id); + toast.success("PDF regenerated"); + setMode("ready"); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to regenerate PDF"; + toast.error(message); + } finally { + setIsRegenerating(false); + } + }, [job, onJobUpdated]); + // Empty state if (!job) { return ( @@ -213,6 +237,19 @@ export const ReadyPanel: React.FC = ({ ); } + // Tailor mode - reuse the same TailorMode component with 'ready' variant + if (mode === "tailor") { + return ( + setMode("ready")} + onFinalize={handleTailorFinalize} + isFinalizing={isRegenerating} + variant="ready" + /> + ); + } + return (
= ({ {/* Fix/Edit actions */} - + setMode("tailor")}> Edit tailoring @@ -345,11 +382,6 @@ export const ReadyPanel: React.FC = ({ {isRegenerating ? "Regenerating..." : "Regenerate PDF"} - - - Edit job description - - {/* Utility actions */} diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index 0af6b53..e25e07e 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -15,6 +15,8 @@ interface TailorModeProps { onBack: () => void; onFinalize: () => void; isFinalizing: boolean; + /** Variant controls the finalize button text. Default is 'discovered'. */ + variant?: 'discovered' | 'ready'; } export const TailorMode: React.FC = ({ @@ -22,6 +24,7 @@ export const TailorMode: React.FC = ({ onBack, onFinalize, isFinalizing, + variant = 'discovered', }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); @@ -274,7 +277,7 @@ export const TailorMode: React.FC = ({
{!canFinalize && (

- Add a summary and select at least one project to finalize. + Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.

)}

- This will generate your tailored PDF and move the job to Ready. + {variant === 'ready' + ? 'This will save your changes and regenerate the tailored PDF.' + : 'This will generate your tailored PDF and move the job to Ready.'}

diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index e7a1297..44ea8cd 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -243,17 +243,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - onEditTailoring={() => { - onSetActiveTab("discovered"); - setTimeout(() => setDetailTab("tailoring"), 50); - }} - onEditDescription={() => { - onSetActiveTab("discovered"); - setTimeout(() => { - setDetailTab("description"); - setIsEditingDescription(true); - }, 50); - }} /> ); } From e5c99d54bf19a47149e091fdaac9fbe7f1a43755 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 14:26:57 +0000 Subject: [PATCH 26/76] formatting improvement --- orchestrator/src/server/services/pdf.ts | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 9af293e..d0a6434 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -49,18 +49,18 @@ export async function generatePdf( selectedProjectIds?: string | null ): Promise { console.log(`📄 Generating PDF for job ${jobId}...`); - + const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json'); - + try { // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { await mkdir(OUTPUT_DIR, { recursive: true }); } - + // Read base resume const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8')); - + // Inject tailored summary if (tailoredContent.summary) { if (baseResume.sections?.summary) { @@ -81,10 +81,10 @@ export async function generatePdf( // Inject tailored skills if (tailoredContent.skills) { - const newSkills = Array.isArray(tailoredContent.skills) - ? tailoredContent.skills - : typeof tailoredContent.skills === 'string' - ? JSON.parse(tailoredContent.skills) + const newSkills = Array.isArray(tailoredContent.skills) + ? tailoredContent.skills + : typeof tailoredContent.skills === 'string' + ? JSON.parse(tailoredContent.skills) : null; if (newSkills && baseResume.sections?.skills) { @@ -131,11 +131,11 @@ export async function generatePdf( } catch (err) { console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err); } - + // Write modified resume to temp file const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`); await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2)); - + // Generate PDF using Python script - output directly to our data folder const outputFilename = `resume_${jobId}.pdf`; const outputPath = join(OUTPUT_DIR, outputFilename); @@ -146,9 +146,9 @@ export async function generatePdf( } catch { // Ignore if it doesn't exist or cannot be removed. } - + await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR); - + // Cleanup temp file try { const { unlink } = await import('fs/promises'); @@ -156,7 +156,7 @@ export async function generatePdf( } catch { // Ignore cleanup errors } - + console.log(`✅ PDF generated: ${outputPath}`); return { success: true, pdfPath: outputPath }; } catch (error) { @@ -177,7 +177,7 @@ async function runPythonPdfGenerator( return new Promise((resolve, reject) => { // Use the virtual environment's Python (or system python in Docker) const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python'); - + const child = spawn(pythonPath, ['rxresume_automation.py'], { cwd: RESUME_GEN_DIR, env: { @@ -188,7 +188,7 @@ async function runPythonPdfGenerator( }, stdio: 'inherit', }); - + child.on('close', (code) => { if (code === 0) { resolve(); @@ -196,7 +196,7 @@ async function runPythonPdfGenerator( reject(new Error(`Python script exited with code ${code}`)); } }); - + child.on('error', reject); }); } From 6a3a25578a0b4225b92c84b5eee23fa7aa39f176 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 14:48:17 +0000 Subject: [PATCH 27/76] use person name from base.json for file downloads --- orchestrator/src/client/api/client.ts | 10 ++- .../src/client/components/ReadyPanel.tsx | 5 +- orchestrator/src/client/hooks/useProfile.ts | 90 +++++++++++++++++++ .../pages/orchestrator/JobDetailPanel.tsx | 5 +- orchestrator/src/server/api/routes/profile.ts | 17 +++- .../src/server/pipeline/orchestrator.ts | 17 +--- orchestrator/src/server/services/index.ts | 1 + orchestrator/src/server/services/pdf.ts | 5 +- orchestrator/src/server/services/profile.ts | 42 +++++++++ .../src/server/services/resumeProjects.ts | 5 ++ 10 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 orchestrator/src/client/hooks/useProfile.ts create mode 100644 orchestrator/src/server/services/profile.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 6558b78..1ed5c9f 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -171,7 +171,15 @@ export async function getSettings(): Promise { } export async function getProfileProjects(): Promise { - return fetchApi('/profile/projects'); + return fetchApi('/profile/projects', { + method: 'POST', + }); +} + +export async function getProfile(): Promise { + return fetchApi('/profile', { + method: 'POST', + }); } diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 580cb63..878b5fb 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -45,6 +45,7 @@ import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils"; import * as api from "../api"; import { FitAssessment, JobHeader, TailoredSummary } from "."; import { TailorMode } from "./discovered-panel/TailorMode"; +import { useProfile } from "../hooks/useProfile"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; type PanelMode = "ready" | "tailor"; @@ -74,6 +75,8 @@ export const ReadyPanel: React.FC = ({ timeoutId: ReturnType; } | null>(null); + const { personName } = useProfile(); + // Load project catalog once useEffect(() => { api.getProfileProjects().then(setCatalog).catch(console.error); @@ -279,7 +282,7 @@ export const ReadyPanel: React.FC = ({
+ {Object.keys(errors).length > 0 && ( +
+ Please fix the errors before saving. +
+ )} - + ) } diff --git a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx index b23dc29..9a70589 100644 --- a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx @@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import type { JobStatus } from "@shared/types" -import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants" +import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants" type DangerZoneSectionProps = { statusesToClear: JobStatus[] diff --git a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx index 88ba312..60c1e6d 100644 --- a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx @@ -1,12 +1,12 @@ import React from "react" +import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Checkbox } from "@/components/ui/checkbox" import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" type DisplaySettingsSectionProps = { - showSponsorInfoDraft: boolean | null - setShowSponsorInfoDraft: (value: boolean | null) => void defaultShowSponsorInfo: boolean effectiveShowSponsorInfo: boolean isLoading: boolean @@ -14,14 +14,12 @@ type DisplaySettingsSectionProps = { } export const DisplaySettingsSection: React.FC = ({ - showSponsorInfoDraft, - setShowSponsorInfoDraft, defaultShowSponsorInfo, effectiveShowSponsorInfo, isLoading, isSaving, }) => { - const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo + const { control } = useFormContext() return ( @@ -31,13 +29,19 @@ export const DisplaySettingsSection: React.FC = ({
- { - setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true) - }} - disabled={isLoading || isSaving} + ( + { + field.onChange(checked === "indeterminate" ? null : checked === true) + }} + disabled={isLoading || isSaving} + /> + )} />