Merge pull request #14 from DaKheera47/self-hosting-improvement
Self hosting improvement
This commit is contained in:
commit
a4ed912de1
@ -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=
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import type {
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
ResumeProfile,
|
||||
} from '../../shared/types';
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
@ -174,6 +175,10 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
|
||||
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<ResumeProfile> {
|
||||
return fetchApi<ResumeProfile>('/profile');
|
||||
}
|
||||
|
||||
|
||||
export async function updateSettings(update: {
|
||||
model?: string | null
|
||||
|
||||
@ -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,16 @@ 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 { useProfile } from "../hooks/useProfile";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
type PanelMode = "ready" | "tailor";
|
||||
|
||||
interface ReadyPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onEditTailoring: () => void;
|
||||
onEditDescription: () => void;
|
||||
}
|
||||
|
||||
const safeFilenamePart = (value: string | null | undefined) =>
|
||||
@ -59,9 +63,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onEditTailoring,
|
||||
onEditDescription,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("ready");
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
@ -72,11 +75,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | null>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
// Load project catalog once
|
||||
useEffect(() => {
|
||||
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 +208,23 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
}
|
||||
}, [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");
|
||||
await onJobUpdated();
|
||||
setMode("ready");
|
||||
} 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 +240,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Tailor mode - reuse the same TailorMode component with 'ready' variant
|
||||
if (mode === "tailor") {
|
||||
return (
|
||||
<TailorMode
|
||||
job={job}
|
||||
onBack={() => setMode("ready")}
|
||||
onFinalize={handleTailorFinalize}
|
||||
isFinalizing={isRegenerating}
|
||||
variant="ready"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<JobHeader
|
||||
@ -242,7 +282,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
||||
<a
|
||||
href={pdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Download PDF</span>
|
||||
@ -332,7 +372,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
{/* Fix/Edit actions */}
|
||||
<DropdownMenuItem onSelect={onEditTailoring}>
|
||||
<DropdownMenuItem onSelect={() => setMode("tailor")}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit tailoring
|
||||
</DropdownMenuItem>
|
||||
@ -345,11 +385,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onEditDescription}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit job description
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Utility actions */}
|
||||
|
||||
@ -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<TailorModeProps> = ({
|
||||
@ -22,6 +24,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
variant = 'discovered',
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
@ -274,7 +277,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
<div className='space-y-2'>
|
||||
{!canFinalize && (
|
||||
<p className='text-[10px] text-center text-muted-foreground'>
|
||||
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'}.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@ -285,17 +288,19 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
{isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Finalizing & generating PDF...
|
||||
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
Finalize & Move to Ready
|
||||
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
91
orchestrator/src/client/hooks/useProfile.ts
Normal file
91
orchestrator/src/client/hooks/useProfile.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { ResumeProfile } from '../../shared/types';
|
||||
|
||||
let profileCache: ResumeProfile | null = null;
|
||||
let profileError: Error | null = null;
|
||||
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
/**
|
||||
* Hook to get the full profile data from base.json.
|
||||
* Caches the result to avoid re-fetching.
|
||||
*/
|
||||
export function useProfile() {
|
||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
||||
const [error, setError] = useState<Error | null>(profileError);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileCache) {
|
||||
setProfile(profileCache);
|
||||
}
|
||||
if (profileError) {
|
||||
setError(profileError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
|
||||
setProfile(newProfile);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!profileCache && !isFetching) {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
api.getProfile()
|
||||
.then((data) => {
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshProfile = async () => {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(profileCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
throw profileError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
error,
|
||||
isLoading: !profile && isFetching && !error,
|
||||
personName: profile?.basics?.name || 'Resume',
|
||||
refreshProfile,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetProfileCache() {
|
||||
profileCache = null;
|
||||
profileError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
@ -66,6 +66,7 @@ vi.mock("../../api", () => ({
|
||||
generateJobPdf: vi.fn(),
|
||||
markAsApplied: vi.fn(),
|
||||
skipJob: vi.fn(),
|
||||
getProfile: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -159,23 +160,7 @@ describe("JobDetailPanel", () => {
|
||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||
});
|
||||
|
||||
it("wires ready panel edit actions back to the page", () => {
|
||||
const onSetActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="ready"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
|
||||
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
|
||||
});
|
||||
|
||||
it("shows an empty state when no job is selected", () => {
|
||||
render(
|
||||
|
||||
@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml }
|
||||
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
|
||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import * as api from "../../api";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab } from "./constants";
|
||||
@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onEditTailoring={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -374,7 +366,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
|
||||
@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import { inferManualJobDetails } from '../../services/manualJob.js';
|
||||
import { scoreJobSuitability } from '../../services/scorer.js';
|
||||
import { loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
import { getProfile } from '../../services/profile.js';
|
||||
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
|
||||
|
||||
export const manualJobsRouter = Router();
|
||||
@ -98,7 +98,7 @@ manualJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||
// Score asynchronously so the import returns immediately.
|
||||
(async () => {
|
||||
try {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
const rawProfile = await getProfile();
|
||||
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format');
|
||||
}
|
||||
|
||||
@ -22,4 +22,12 @@ describe.sequential('Profile API routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns full base resume profile', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
expect(typeof body.data).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { extractProjectsFromProfile, loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
|
||||
import { getProfile } from '../../services/profile.js';
|
||||
|
||||
export const profileRouter = Router();
|
||||
|
||||
@ -8,7 +9,7 @@ export const profileRouter = Router();
|
||||
*/
|
||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = await loadResumeProfile();
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
res.json({ success: true, data: catalog });
|
||||
} catch (error) {
|
||||
@ -16,3 +17,16 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/profile - Get the full base resume profile
|
||||
*/
|
||||
profileRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = await getProfile();
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,10 +3,10 @@ import { z } from 'zod';
|
||||
import * as settingsRepo from '../../repositories/settings.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
loadResumeProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../../services/resumeProjects.js';
|
||||
import { getProfile } from '../../services/profile.js';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@ -37,7 +37,7 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
@ -216,7 +216,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
if (resumeProjects === null) {
|
||||
await settingsRepo.setSetting('resumeProjects', null);
|
||||
} else {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
const rawProfile = await getProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
@ -301,7 +301,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
@ -16,6 +16,7 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
|
||||
import { scoreJobSuitability } from '../services/scorer.js';
|
||||
import { generateTailoring } from '../services/summary.js';
|
||||
import { generatePdf } from '../services/pdf.js';
|
||||
import { getProfile } from '../services/profile.js';
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from '../services/projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js';
|
||||
@ -112,7 +113,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
try {
|
||||
// Step 1: Load profile
|
||||
console.log('\n📋 Loading profile...');
|
||||
const profile = await loadProfile(mergedConfig.profilePath);
|
||||
const profile = await getProfile(mergedConfig.profilePath);
|
||||
|
||||
// Step 2: Run crawler
|
||||
console.log('\n🕷️ Running crawler...');
|
||||
@ -346,7 +347,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
|
||||
// Process job (Generate Summary + PDF)
|
||||
// We catch errors here to ensure one failure doesn't stop the whole batch
|
||||
const result = await processJob(job.id);
|
||||
const result = await processJob(job.id, { profilePath: mergedConfig.profilePath });
|
||||
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
@ -413,12 +414,17 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export type ProcessJobOptions = {
|
||||
force?: boolean;
|
||||
profilePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Step 1: Generate AI summary and suggest projects.
|
||||
*/
|
||||
export async function summarizeJob(
|
||||
jobId: string,
|
||||
options?: { force?: boolean }
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -429,7 +435,7 @@ export async function summarizeJob(
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: 'Job not found' };
|
||||
|
||||
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
|
||||
const profile = await getProfile(options?.profilePath);
|
||||
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
@ -490,7 +496,8 @@ export async function summarizeJob(
|
||||
* Step 2: Generate PDF using current summary and project selection.
|
||||
*/
|
||||
export async function generateFinalPdf(
|
||||
jobId: string
|
||||
jobId: string,
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -512,7 +519,7 @@ export async function generateFinalPdf(
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
|
||||
},
|
||||
job.jobDescription || '',
|
||||
DEFAULT_PROFILE_PATH,
|
||||
options?.profilePath || DEFAULT_PROFILE_PATH,
|
||||
job.selectedProjectIds
|
||||
);
|
||||
|
||||
@ -539,7 +546,7 @@ export async function generateFinalPdf(
|
||||
*/
|
||||
export async function processJob(
|
||||
jobId: string,
|
||||
options?: { force?: boolean }
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -550,7 +557,7 @@ export async function processJob(
|
||||
if (!sumResult.success) return sumResult;
|
||||
|
||||
// Step 2: Generate PDF
|
||||
const pdfResult = await generateFinalPdf(jobId);
|
||||
const pdfResult = await generateFinalPdf(jobId, options);
|
||||
return pdfResult;
|
||||
|
||||
} catch (error) {
|
||||
@ -566,15 +573,3 @@ export function getPipelineStatus(): { isRunning: boolean } {
|
||||
return { isRunning: isPipelineRunning };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user profile from JSON file.
|
||||
*/
|
||||
async function loadProfile(profilePath: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const content = await readFile(profilePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load profile, using empty object');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ export * from './scorer.js';
|
||||
export * from './summary.js';
|
||||
export * from './pdf.js';
|
||||
export * from './notion.js';
|
||||
export * from './profile.js';
|
||||
|
||||
@ -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<ManualJobInferenceResult> {
|
||||
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<ManualJobInferenceResult> {
|
||||
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<Man
|
||||
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const prompt = buildInferencePrompt(jobDescription);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
const result = await callOpenRouter<ManualJobApiResponse>({
|
||||
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,58 +125,23 @@ OUTPUT FORMAT (JSON ONLY):
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function parseJsonFromContent(content: string): Record<string, unknown> {
|
||||
const trimmed = content.trim();
|
||||
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFences);
|
||||
} catch {
|
||||
const firstBrace = withoutFences.indexOf('{');
|
||||
const lastBrace = withoutFences.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sliced = withoutFences.slice(firstBrace, lastBrace + 1);
|
||||
return JSON.parse(sliced);
|
||||
}
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
|
||||
const fields: Array<keyof ManualJobDraft> = [
|
||||
'title',
|
||||
'employer',
|
||||
'location',
|
||||
'salary',
|
||||
'deadline',
|
||||
'jobUrl',
|
||||
'applicationLink',
|
||||
'jobType',
|
||||
'jobLevel',
|
||||
'jobFunction',
|
||||
'disciplines',
|
||||
'degreeRequired',
|
||||
'starting',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
199
orchestrator/src/server/services/openrouter.test.ts
Normal file
199
orchestrator/src/server/services/openrouter.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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,
|
||||
text: async () => 'Internal Server Error',
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
167
orchestrator/src/server/services/openrouter.ts
Normal file
167
orchestrator/src/server/services/openrouter.ts
Normal file
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
required: string[];
|
||||
additionalProperties: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenRouterRequestOptions<T> {
|
||||
/** 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<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface OpenRouterError {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
|
||||
|
||||
/**
|
||||
* Call OpenRouter API with structured JSON output.
|
||||
*
|
||||
* @returns Parsed JSON response matching the schema, or an error object
|
||||
*/
|
||||
export async function callOpenRouter<T>(
|
||||
options: OpenRouterRequestOptions<T>
|
||||
): Promise<OpenRouterResponse<T>> {
|
||||
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 error with status to allow specific retries
|
||||
const errorBody = await response.text().catch(() => 'No error body');
|
||||
const err = new Error(`OpenRouter API error: ${response.status}`);
|
||||
(err as any).status = response.status;
|
||||
(err as any).body = errorBody;
|
||||
throw err;
|
||||
}
|
||||
|
||||
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<T>(content, jobId);
|
||||
|
||||
return { success: true, data: parsed };
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = (error as any).status;
|
||||
|
||||
// Retry on:
|
||||
// 1. Parsing errors (AI returned malformed JSON)
|
||||
// 2. Rate limits (429)
|
||||
// 3. Server errors (5xx)
|
||||
// 4. Timeouts/Network issues
|
||||
const shouldRetry =
|
||||
message.includes('parse') ||
|
||||
status === 429 ||
|
||||
(status >= 500 && status <= 599) ||
|
||||
message.toLowerCase().includes('timeout') ||
|
||||
message.toLowerCase().includes('fetch failed');
|
||||
|
||||
if (attempt < maxRetries && shouldRetry) {
|
||||
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed (${status ?? 'no-status'}): ${message}. Retrying...`);
|
||||
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<T>(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
|
||||
// Use non-greedy match and find the outermost braces
|
||||
const firstBrace = candidate.indexOf('{');
|
||||
const lastBrace = candidate.lastIndexOf('}');
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
candidate = candidate.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
159
orchestrator/src/server/services/pdf-skills-validation.test.ts
Normal file
159
orchestrator/src/server/services/pdf-skills-validation.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { generatePdf } from './pdf.js';
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
sections: {
|
||||
summary: { content: 'Original Summary' },
|
||||
skills: {
|
||||
items: [
|
||||
{ id: 's1', name: 'Existing Skill', visible: true, description: 'Existing Desc', level: 3, keywords: ['k1'] }
|
||||
]
|
||||
},
|
||||
projects: { items: [] }
|
||||
},
|
||||
basics: { headline: 'Original Headline' }
|
||||
};
|
||||
|
||||
return {
|
||||
mockProfile: profile,
|
||||
mocks: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Configure base mock implementations
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
mocks.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
return {
|
||||
default: mocks,
|
||||
...mocks
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
default: { existsSync: vi.fn().mockReturnValue(true) }
|
||||
}));
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('./projectSelection.js', () => ({
|
||||
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('./resumeProjects.js', () => ({
|
||||
extractProjectsFromProfile: vi.fn().mockReturnValue({ catalog: [], selectionItems: [] }),
|
||||
resolveResumeProjectsSettings: vi.fn().mockReturnValue({
|
||||
resumeProjects: { lockedProjectIds: [], aiSelectableProjectIds: [], maxProjects: 2 }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn().mockImplementation(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') cb(0);
|
||||
return {};
|
||||
}),
|
||||
})),
|
||||
default: {
|
||||
spawn: vi.fn().mockImplementation(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') cb(0);
|
||||
return {};
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
describe('PDF Service Skills Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
});
|
||||
|
||||
it('should add required schema fields (visible, description) to new skills', async () => {
|
||||
// AI often returns just name and keywords
|
||||
const newSkills = [
|
||||
{ name: 'New Skill', keywords: ['k2'] },
|
||||
{ name: 'Existing Skill', keywords: ['k3', 'k4'] } // Should merge with s1
|
||||
];
|
||||
|
||||
const tailoredContent = { skills: newSkills };
|
||||
|
||||
await generatePdf('job-skills-1', tailoredContent, 'Job Desc');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skillItems = savedResumeJson.sections.skills.items;
|
||||
|
||||
// Check "New Skill"
|
||||
const newSkill = skillItems.find((s: any) => s.name === 'New Skill');
|
||||
expect(newSkill).toBeDefined();
|
||||
|
||||
// These are the validations failing in user report:
|
||||
expect(newSkill.visible).toBe(true); // Should default to true
|
||||
expect(typeof newSkill.description).toBe('string'); // Should default to ""
|
||||
expect(newSkill.description).toBe('');
|
||||
// Optional but good to check
|
||||
expect(newSkill.id).toBeDefined();
|
||||
expect(newSkill.level).toBe(1);
|
||||
|
||||
// Check "Existing Skill" - should preserve existing fields if not overwritten?
|
||||
// In the implementation, we look up existing.
|
||||
// existing.visible => true, existing.description => 'Existing Desc', existing.level => 3
|
||||
const existingSkill = skillItems.find((s: any) => s.name === 'Existing Skill');
|
||||
expect(existingSkill.visible).toBe(true);
|
||||
expect(existingSkill.description).toBe('Existing Desc');
|
||||
expect(existingSkill.level).toBe(3);
|
||||
expect(existingSkill.keywords).toEqual(['k3', 'k4']); // Should use new keywords or existing? Implementation uses new || existing.
|
||||
});
|
||||
|
||||
it('should sanitize base resume even if no skills are tailored', async () => {
|
||||
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
|
||||
// though our mock above has them. Let's make a truly invalid one locally)
|
||||
const invalidProfile = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Invalid Skill' } // Missing visible, description, id, level
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile));
|
||||
|
||||
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
|
||||
await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const item = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// Ensure defaults are applied even if we didn't use the tailoring logic block
|
||||
expect(item.visible).toBe(true);
|
||||
expect(item.description).toBe('');
|
||||
expect(item.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -13,6 +13,7 @@ import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from './projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
import { getProfile } from './profile.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@ -49,18 +50,34 @@ export async function generatePdf(
|
||||
selectedProjectIds?: string | null
|
||||
): Promise<PdfResult> {
|
||||
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'));
|
||||
|
||||
const baseResume = baseResumePath
|
||||
? JSON.parse(await readFile(baseResumePath, 'utf-8'))
|
||||
: JSON.parse(JSON.stringify(await getProfile())); // Deep copy from cache
|
||||
|
||||
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
|
||||
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
|
||||
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
|
||||
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({
|
||||
...skill,
|
||||
id: skill.id || `skill-${index}`,
|
||||
visible: skill.visible ?? true,
|
||||
// Zod schema requires string, default to empty string if missing
|
||||
description: skill.description ?? '',
|
||||
level: skill.level ?? 1,
|
||||
keywords: skill.keywords || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Inject tailored summary
|
||||
if (tailoredContent.summary) {
|
||||
if (baseResume.sections?.summary) {
|
||||
@ -81,14 +98,30 @@ 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) {
|
||||
baseResume.sections.skills.items = newSkills;
|
||||
// Ensure each skill item has required schema fields
|
||||
const existingSkills = baseResume.sections.skills.items || [];
|
||||
const skillsWithSchema = newSkills.map((newSkill: any, index: number) => {
|
||||
// Try to find matching existing skill to preserve id and other fields
|
||||
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
|
||||
|
||||
return {
|
||||
id: newSkill.id || existing?.id || `skill-${index}`,
|
||||
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
|
||||
name: newSkill.name || existing?.name || '',
|
||||
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
|
||||
level: newSkill.level !== undefined ? newSkill.level : (existing?.level ?? 1),
|
||||
keywords: newSkill.keywords || existing?.keywords || [],
|
||||
};
|
||||
});
|
||||
|
||||
baseResume.sections.skills.items = skillsWithSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,11 +164,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 +179,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 +189,7 @@ export async function generatePdf(
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
|
||||
console.log(`✅ PDF generated: ${outputPath}`);
|
||||
return { success: true, pdfPath: outputPath };
|
||||
} catch (error) {
|
||||
@ -177,7 +210,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 +221,7 @@ async function runPythonPdfGenerator(
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
@ -196,7 +229,7 @@ async function runPythonPdfGenerator(
|
||||
reject(new Error(`Python script exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
32
orchestrator/src/server/services/profile.test.ts
Normal file
32
orchestrator/src/server/services/profile.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { getProfile } from './profile.js';
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const fn = vi.fn();
|
||||
return {
|
||||
readFile: fn,
|
||||
default: {
|
||||
readFile: fn
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('getProfile failure', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file does not exist', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
|
||||
await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory');
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file is invalid JSON', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await expect(getProfile('/invalid/json.json', true)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
48
orchestrator/src/server/services/profile.ts
Normal file
48
orchestrator/src/server/services/profile.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||
|
||||
let cachedProfile: any = null;
|
||||
let cachedProfilePath: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from base.json.
|
||||
* Caches the result since it doesn't change often.
|
||||
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
||||
* @param forceRefresh Force reload from disk.
|
||||
*/
|
||||
export async function getProfile(profilePath?: string, forceRefresh = false): Promise<any> {
|
||||
const targetPath = profilePath || DEFAULT_PROFILE_PATH;
|
||||
|
||||
if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) {
|
||||
return cachedProfile;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(targetPath, 'utf-8');
|
||||
cachedProfile = JSON.parse(content);
|
||||
cachedProfilePath = targetPath;
|
||||
return cachedProfile;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load profile from ${targetPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the person's name from the profile.
|
||||
*/
|
||||
export async function getPersonName(): Promise<string> {
|
||||
const profile = await getProfile();
|
||||
return profile?.basics?.name || 'Resume';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the profile cache.
|
||||
*/
|
||||
export function clearProfileCache(): void {
|
||||
cachedProfile = null;
|
||||
}
|
||||
@ -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<string>();
|
||||
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<string>();
|
||||
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()}…`;
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
});
|
||||
|
||||
@ -4,18 +4,12 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
|
||||
|
||||
import { getProfile, DEFAULT_PROFILE_PATH } from './profile.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const DEFAULT_RESUME_PROFILE_PATH =
|
||||
process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||
|
||||
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
|
||||
|
||||
export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise<unknown> {
|
||||
const content = await readFile(profilePath, 'utf-8');
|
||||
return JSON.parse(content) as unknown;
|
||||
}
|
||||
|
||||
export function extractProjectsFromProfile(profile: unknown): {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
selectionItems: ResumeProjectSelectionItem[];
|
||||
@ -58,7 +52,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(
|
||||
|
||||
241
orchestrator/src/server/services/scorer.test.ts
Normal file
241
orchestrator/src/server/services/scorer.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,23 +4,42 @@
|
||||
|
||||
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.
|
||||
*/
|
||||
export async function scoreJobSuitability(
|
||||
job: Job,
|
||||
profile: Record<string, unknown>
|
||||
): Promise<SuitabilityResult> {
|
||||
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);
|
||||
}
|
||||
@ -29,80 +48,130 @@ 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',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
const parsed = parseJsonFromContent(content);
|
||||
return {
|
||||
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);
|
||||
const prompt = buildScoringPrompt(job, profile);
|
||||
|
||||
const result = await callOpenRouter<{ score: number; reason: string }>({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
jsonSchema: SCORING_SCHEMA,
|
||||
maxRetries: 2,
|
||||
jobId: job.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`);
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @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;
|
||||
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, unknown>): 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 +179,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": "<brief explanation>" }
|
||||
`;
|
||||
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
||||
|
||||
REQUIRED FORMAT (exactly this structure):
|
||||
{"score": <integer 0-100>, "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 +235,6 @@ export async function scoreAndRankJobs(
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
|
||||
}
|
||||
|
||||
@ -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<string, unknown>
|
||||
): Promise<TailoringResult> {
|
||||
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<TailoredData>({
|
||||
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<string, unknown>, 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<string, unknown>, 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)}
|
||||
|
||||
895
orchestrator/src/shared/rxresume-schema.ts
Normal file
895
orchestrator/src/shared/rxresume-schema.ts
Normal file
@ -0,0 +1,895 @@
|
||||
// combined types from: https://github.com/amruthpillai/reactive-resume/tree/v4.5.5/libs/schema/src
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
export type FilterKeys<T, Condition> = {
|
||||
[Key in keyof T]: T[Key] extends Condition ? Key : never;
|
||||
}[keyof T];
|
||||
|
||||
export const idSchema = z
|
||||
.string()
|
||||
.describe("Unique identifier for the item");
|
||||
|
||||
export const itemSchema = z.object({
|
||||
id: idSchema,
|
||||
visible: z.boolean(),
|
||||
});
|
||||
|
||||
export type Item = z.infer<typeof itemSchema>;
|
||||
|
||||
export const defaultItem: Item = {
|
||||
id: "",
|
||||
visible: true,
|
||||
};
|
||||
|
||||
export const urlSchema = z.object({
|
||||
label: z.string(),
|
||||
href: z.literal("").or(z.string().url()),
|
||||
});
|
||||
|
||||
export type URL = z.infer<typeof urlSchema>;
|
||||
|
||||
export const defaultUrl: URL = {
|
||||
label: "",
|
||||
href: "",
|
||||
};
|
||||
|
||||
// --- Basics ---
|
||||
|
||||
export const customFieldSchema = z.object({
|
||||
id: z.string().cuid2(),
|
||||
icon: z.string(),
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export type CustomField = z.infer<typeof customFieldSchema>;
|
||||
|
||||
export const basicsSchema = z.object({
|
||||
name: z.string(),
|
||||
headline: z.string(),
|
||||
email: z.literal("").or(z.string().email()),
|
||||
phone: z.string(),
|
||||
location: z.string(),
|
||||
url: urlSchema,
|
||||
customFields: z.array(customFieldSchema),
|
||||
picture: z.object({
|
||||
url: z.string(),
|
||||
size: z.number().default(64),
|
||||
aspectRatio: z.number().default(1),
|
||||
borderRadius: z.number().default(0),
|
||||
effects: z.object({
|
||||
hidden: z.boolean().default(false),
|
||||
border: z.boolean().default(false),
|
||||
grayscale: z.boolean().default(false),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type Basics = z.infer<typeof basicsSchema>;
|
||||
|
||||
export const defaultBasics: Basics = {
|
||||
name: "",
|
||||
headline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
url: defaultUrl,
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: "",
|
||||
size: 64,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: {
|
||||
hidden: false,
|
||||
border: false,
|
||||
grayscale: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// --- Metadata ---
|
||||
|
||||
export const defaultLayout = [
|
||||
[
|
||||
["profiles", "summary", "experience", "education", "projects", "volunteer", "references"],
|
||||
["skills", "interests", "certifications", "awards", "publications", "languages"],
|
||||
],
|
||||
];
|
||||
|
||||
export const metadataSchema = z.object({
|
||||
template: z.string().default("rhyhorn"),
|
||||
layout: z.array(z.array(z.array(z.string()))).default(defaultLayout), // pages -> columns -> sections
|
||||
css: z.object({
|
||||
value: z.string().default("* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"),
|
||||
visible: z.boolean().default(false),
|
||||
}),
|
||||
page: z.object({
|
||||
margin: z.number().default(18),
|
||||
format: z.enum(["a4", "letter"]).default("a4"),
|
||||
options: z.object({
|
||||
breakLine: z.boolean().default(true),
|
||||
pageNumbers: z.boolean().default(true),
|
||||
}),
|
||||
}),
|
||||
theme: z.object({
|
||||
background: z.string().default("#ffffff"),
|
||||
text: z.string().default("#000000"),
|
||||
primary: z.string().default("#dc2626"),
|
||||
}),
|
||||
typography: z.object({
|
||||
font: z.object({
|
||||
family: z.string().default("IBM Plex Serif"),
|
||||
subset: z.string().default("latin"),
|
||||
variants: z.array(z.string()).default(["regular"]),
|
||||
size: z.number().default(14),
|
||||
}),
|
||||
lineHeight: z.number().default(1.5),
|
||||
hideIcons: z.boolean().default(false),
|
||||
underlineLinks: z.boolean().default(true),
|
||||
}),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
export type Metadata = z.infer<typeof metadataSchema>;
|
||||
|
||||
export const defaultMetadata: Metadata = {
|
||||
template: "rhyhorn",
|
||||
layout: defaultLayout,
|
||||
css: {
|
||||
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
|
||||
visible: false,
|
||||
},
|
||||
page: {
|
||||
margin: 18,
|
||||
format: "a4",
|
||||
options: {
|
||||
breakLine: true,
|
||||
pageNumbers: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
background: "#ffffff",
|
||||
text: "#000000",
|
||||
primary: "#dc2626",
|
||||
},
|
||||
typography: {
|
||||
font: {
|
||||
family: "IBM Plex Serif",
|
||||
subset: "latin",
|
||||
variants: ["regular", "italic", "600"],
|
||||
size: 14,
|
||||
},
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: "",
|
||||
};
|
||||
|
||||
// --- Sections ---
|
||||
|
||||
// Award
|
||||
export const awardSchema = itemSchema.extend({
|
||||
title: z.string().min(1),
|
||||
awarder: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Award = z.infer<typeof awardSchema>;
|
||||
|
||||
export const defaultAward: Award = {
|
||||
...defaultItem,
|
||||
title: "",
|
||||
awarder: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Certification
|
||||
export const certificationSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
issuer: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Certification = z.infer<typeof certificationSchema>;
|
||||
|
||||
export const defaultCertification: Certification = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
issuer: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Custom Section
|
||||
export const customSectionSchema = itemSchema.extend({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
date: z.string(),
|
||||
location: z.string(),
|
||||
summary: z.string(),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type CustomSection = z.infer<typeof customSectionSchema>;
|
||||
|
||||
export const defaultCustomSection: CustomSection = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
description: "",
|
||||
date: "",
|
||||
location: "",
|
||||
summary: "",
|
||||
keywords: [],
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Education
|
||||
export const educationSchema = itemSchema.extend({
|
||||
institution: z.string().min(1),
|
||||
studyType: z.string(),
|
||||
area: z.string(),
|
||||
score: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Education = z.infer<typeof educationSchema>;
|
||||
|
||||
export const defaultEducation: Education = {
|
||||
...defaultItem,
|
||||
id: "",
|
||||
institution: "",
|
||||
studyType: "",
|
||||
area: "",
|
||||
score: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Experience
|
||||
export const experienceSchema = itemSchema.extend({
|
||||
company: z.string().min(1),
|
||||
position: z.string(),
|
||||
location: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Experience = z.infer<typeof experienceSchema>;
|
||||
|
||||
export const defaultExperience: Experience = {
|
||||
...defaultItem,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Interest
|
||||
export const interestSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type Interest = z.infer<typeof interestSchema>;
|
||||
|
||||
export const defaultInterest: Interest = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
// Language
|
||||
export const languageSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
level: z.coerce.number().min(0).max(5).default(1),
|
||||
});
|
||||
|
||||
export type Language = z.infer<typeof languageSchema>;
|
||||
|
||||
export const defaultLanguage: Language = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
description: "",
|
||||
level: 1,
|
||||
};
|
||||
|
||||
// Profile
|
||||
export const profileSchema = itemSchema.extend({
|
||||
network: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
icon: z
|
||||
.string()
|
||||
.describe(
|
||||
'Slug for the icon from https://simpleicons.org. For example, "github", "linkedin", etc.',
|
||||
),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
export const defaultProfile: Profile = {
|
||||
...defaultItem,
|
||||
network: "",
|
||||
username: "",
|
||||
icon: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Project
|
||||
export const projectSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Project = z.infer<typeof projectSchema>;
|
||||
|
||||
export const defaultProject: Project = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
description: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
keywords: [],
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Publication
|
||||
export const publicationSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
publisher: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Publication = z.infer<typeof publicationSchema>;
|
||||
|
||||
export const defaultPublication: Publication = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
publisher: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Reference
|
||||
export const referenceSchema = itemSchema.extend({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Reference = z.infer<typeof referenceSchema>;
|
||||
|
||||
export const defaultReference: Reference = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
description: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// Skill
|
||||
export const skillSchema = itemSchema.extend({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
level: z.coerce.number().min(0).max(5).default(1),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type Skill = z.infer<typeof skillSchema>;
|
||||
|
||||
export const defaultSkill: Skill = {
|
||||
...defaultItem,
|
||||
name: "",
|
||||
description: "",
|
||||
level: 1,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
// Volunteer
|
||||
export const volunteerSchema = itemSchema.extend({
|
||||
organization: z.string().min(1),
|
||||
position: z.string(),
|
||||
location: z.string(),
|
||||
date: z.string(),
|
||||
summary: z.string(),
|
||||
url: urlSchema,
|
||||
});
|
||||
|
||||
export type Volunteer = z.infer<typeof volunteerSchema>;
|
||||
|
||||
export const defaultVolunteer: Volunteer = {
|
||||
...defaultItem,
|
||||
organization: "",
|
||||
position: "",
|
||||
location: "",
|
||||
date: "",
|
||||
summary: "",
|
||||
url: defaultUrl,
|
||||
};
|
||||
|
||||
// --- Aggregate Sections ---
|
||||
|
||||
export const sectionSchema = z.object({
|
||||
name: z.string(),
|
||||
columns: z.number().min(1).max(5).default(1),
|
||||
separateLinks: z.boolean().default(true),
|
||||
visible: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const customSchema = sectionSchema.extend({
|
||||
id: idSchema,
|
||||
items: z.array(customSectionSchema),
|
||||
});
|
||||
|
||||
export const sectionsSchema = z.object({
|
||||
summary: sectionSchema.extend({
|
||||
id: z.literal("summary"),
|
||||
content: z.string().default(""),
|
||||
}),
|
||||
awards: sectionSchema.extend({
|
||||
id: z.literal("awards"),
|
||||
items: z.array(awardSchema),
|
||||
}),
|
||||
certifications: sectionSchema.extend({
|
||||
id: z.literal("certifications"),
|
||||
items: z.array(certificationSchema),
|
||||
}),
|
||||
education: sectionSchema.extend({
|
||||
id: z.literal("education"),
|
||||
items: z.array(educationSchema),
|
||||
}),
|
||||
experience: sectionSchema.extend({
|
||||
id: z.literal("experience"),
|
||||
items: z.array(experienceSchema),
|
||||
}),
|
||||
volunteer: sectionSchema.extend({
|
||||
id: z.literal("volunteer"),
|
||||
items: z.array(volunteerSchema),
|
||||
}),
|
||||
interests: sectionSchema.extend({
|
||||
id: z.literal("interests"),
|
||||
items: z.array(interestSchema),
|
||||
}),
|
||||
languages: sectionSchema.extend({
|
||||
id: z.literal("languages"),
|
||||
items: z.array(languageSchema),
|
||||
}),
|
||||
profiles: sectionSchema.extend({
|
||||
id: z.literal("profiles"),
|
||||
items: z.array(profileSchema),
|
||||
}),
|
||||
projects: sectionSchema.extend({
|
||||
id: z.literal("projects"),
|
||||
items: z.array(projectSchema),
|
||||
}),
|
||||
publications: sectionSchema.extend({
|
||||
id: z.literal("publications"),
|
||||
items: z.array(publicationSchema),
|
||||
}),
|
||||
references: sectionSchema.extend({
|
||||
id: z.literal("references"),
|
||||
items: z.array(referenceSchema),
|
||||
}),
|
||||
skills: sectionSchema.extend({
|
||||
id: z.literal("skills"),
|
||||
items: z.array(skillSchema),
|
||||
}),
|
||||
custom: z.record(z.string(), customSchema),
|
||||
});
|
||||
|
||||
export type Section = z.infer<typeof sectionSchema>;
|
||||
export type Sections = z.infer<typeof sectionsSchema>;
|
||||
|
||||
export type SectionKey = "basics" | keyof Sections | `custom.${string}`;
|
||||
export type SectionWithItem<T = unknown> = Sections[FilterKeys<Sections, { items: T[] }>];
|
||||
export type SectionItem = SectionWithItem["items"][number];
|
||||
export type CustomSectionGroup = z.infer<typeof customSchema>;
|
||||
|
||||
export const defaultSection: Section = {
|
||||
name: "",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
export const defaultSections: Sections = {
|
||||
summary: { ...defaultSection, id: "summary", name: "Summary", content: "" },
|
||||
awards: { ...defaultSection, id: "awards", name: "Awards", items: [] },
|
||||
certifications: { ...defaultSection, id: "certifications", name: "Certifications", items: [] },
|
||||
education: { ...defaultSection, id: "education", name: "Education", items: [] },
|
||||
experience: { ...defaultSection, id: "experience", name: "Experience", items: [] },
|
||||
volunteer: { ...defaultSection, id: "volunteer", name: "Volunteering", items: [] },
|
||||
interests: { ...defaultSection, id: "interests", name: "Interests", items: [] },
|
||||
languages: { ...defaultSection, id: "languages", name: "Languages", items: [] },
|
||||
profiles: { ...defaultSection, id: "profiles", name: "Profiles", items: [] },
|
||||
projects: { ...defaultSection, id: "projects", name: "Projects", items: [] },
|
||||
publications: { ...defaultSection, id: "publications", name: "Publications", items: [] },
|
||||
references: { ...defaultSection, id: "references", name: "References", items: [] },
|
||||
skills: { ...defaultSection, id: "skills", name: "Skills", items: [] },
|
||||
custom: {},
|
||||
};
|
||||
|
||||
// --- Main Resume Data ---
|
||||
|
||||
export const resumeDataSchema = z.object({
|
||||
basics: basicsSchema,
|
||||
sections: sectionsSchema,
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
export type ResumeData = z.infer<typeof resumeDataSchema>;
|
||||
|
||||
export const defaultResumeData: ResumeData = {
|
||||
basics: defaultBasics,
|
||||
sections: defaultSections,
|
||||
metadata: defaultMetadata,
|
||||
};
|
||||
|
||||
// --- Sample Data ---
|
||||
|
||||
export const sampleResume: ResumeData = {
|
||||
basics: {
|
||||
name: "John Doe",
|
||||
headline: "Creative and Innovative Web Developer",
|
||||
email: "john.doe@gmail.com",
|
||||
phone: "(555) 123-4567",
|
||||
location: "Pleasantville, CA 94588",
|
||||
url: {
|
||||
label: "",
|
||||
href: "https://johndoe.me/",
|
||||
},
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: "https://i.imgur.com/HgwyOuJ.jpg",
|
||||
size: 120,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: {
|
||||
hidden: false,
|
||||
border: false,
|
||||
grayscale: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: {
|
||||
name: "Summary",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "summary",
|
||||
content:
|
||||
"<p>Innovative Web Developer with 5 years of experience in building impactful and user-friendly websites and applications. Specializes in <strong>front-end technologies</strong> and passionate about modern web standards and cutting-edge development techniques. Proven track record of leading successful projects from concept to deployment.</p>",
|
||||
},
|
||||
awards: {
|
||||
name: "Awards",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "awards",
|
||||
items: [],
|
||||
},
|
||||
certifications: {
|
||||
name: "Certifications",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "certifications",
|
||||
items: [
|
||||
{
|
||||
id: "spdhh9rrqi1gvj0yqnbqunlo",
|
||||
visible: true,
|
||||
name: "Full-Stack Web Development",
|
||||
issuer: "CodeAcademy",
|
||||
date: "2020",
|
||||
summary: "",
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "n838rddyqv47zexn6cxauwqp",
|
||||
visible: true,
|
||||
name: "AWS Certified Developer",
|
||||
issuer: "Amazon Web Services",
|
||||
date: "2019",
|
||||
summary: "",
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
education: {
|
||||
name: "Education",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "education",
|
||||
items: [
|
||||
{
|
||||
id: "yo3p200zo45c6cdqc6a2vtt3",
|
||||
visible: true,
|
||||
institution: "University of California",
|
||||
studyType: "Bachelor's in Computer Science",
|
||||
area: "Berkeley, CA",
|
||||
score: "",
|
||||
date: "August 2012 to May 2016",
|
||||
summary: "",
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
experience: {
|
||||
name: "Experience",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "experience",
|
||||
items: [
|
||||
{
|
||||
id: "lhw25d7gf32wgdfpsktf6e0x",
|
||||
visible: true,
|
||||
company: "Creative Solutions Inc.",
|
||||
position: "Senior Web Developer",
|
||||
location: "San Francisco, CA",
|
||||
date: "January 2019 to Present",
|
||||
summary:
|
||||
"<ul><li><p>Spearheaded the redesign of the main product website, resulting in a 40% increase in user engagement.</p></li><li><p>Developed and implemented a new responsive framework, improving cross-device compatibility.</p></li><li><p>Mentored a team of four junior developers, fostering a culture of technical excellence.</p></li></ul>",
|
||||
url: {
|
||||
label: "",
|
||||
href: "https://creativesolutions.inc/",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "r6543lil53ntrxmvel53gbtm",
|
||||
visible: true,
|
||||
company: "TechAdvancers",
|
||||
position: "Web Developer",
|
||||
location: "San Jose, CA",
|
||||
date: "June 2016 to December 2018",
|
||||
summary:
|
||||
"<ul><li><p>Collaborated in a team of 10 to develop high-quality web applications using React.js and Node.js.</p></li><li><p>Managed the integration of third-party services such as Stripe for payments and Twilio for SMS services.</p></li><li><p>Optimized application performance, achieving a 30% reduction in load times.</p></li></ul>",
|
||||
url: {
|
||||
label: "",
|
||||
href: "https://techadvancers.com/",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
volunteer: {
|
||||
name: "Volunteering",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "volunteer",
|
||||
items: [],
|
||||
},
|
||||
interests: {
|
||||
name: "Interests",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "interests",
|
||||
items: [],
|
||||
},
|
||||
languages: {
|
||||
name: "Languages",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "languages",
|
||||
items: [],
|
||||
},
|
||||
profiles: {
|
||||
name: "Profiles",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "profiles",
|
||||
items: [
|
||||
{
|
||||
id: "cnbk5f0aeqvhx69ebk7hktwd",
|
||||
visible: true,
|
||||
network: "LinkedIn",
|
||||
username: "johndoe",
|
||||
icon: "linkedin",
|
||||
url: {
|
||||
label: "",
|
||||
href: "https://linkedin.com/in/johndoe",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ukl0uecvzkgm27mlye0wazlb",
|
||||
visible: true,
|
||||
network: "GitHub",
|
||||
username: "johndoe",
|
||||
icon: "github",
|
||||
url: {
|
||||
label: "",
|
||||
href: "https://github.com/johndoe",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projects: {
|
||||
name: "Projects",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "projects",
|
||||
items: [
|
||||
{
|
||||
id: "yw843emozcth8s1ubi1ubvlf",
|
||||
visible: true,
|
||||
name: "E-Commerce Platform",
|
||||
description: "Project Lead",
|
||||
date: "",
|
||||
summary:
|
||||
"<p>Led the development of a full-stack e-commerce platform, improving sales conversion by 25%.</p>",
|
||||
keywords: [],
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ncxgdjjky54gh59iz2t1xi1v",
|
||||
visible: true,
|
||||
name: "Interactive Dashboard",
|
||||
description: "Frontend Developer",
|
||||
date: "",
|
||||
summary:
|
||||
"<p>Created an interactive analytics dashboard for a SaaS application, enhancing data visualization for clients.</p>",
|
||||
keywords: [],
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
publications: {
|
||||
name: "Publications",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "publications",
|
||||
items: [],
|
||||
},
|
||||
references: {
|
||||
name: "References",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: false,
|
||||
id: "references",
|
||||
items: [
|
||||
{
|
||||
id: "f2sv5z0cce6ztjl87yuk8fak",
|
||||
visible: true,
|
||||
name: "Available upon request",
|
||||
description: "",
|
||||
summary: "",
|
||||
url: {
|
||||
label: "",
|
||||
href: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
name: "Skills",
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
id: "skills",
|
||||
items: [
|
||||
{
|
||||
id: "hn0keriukh6c0ojktl9gsgjm",
|
||||
visible: true,
|
||||
name: "Web Technologies",
|
||||
description: "Advanced",
|
||||
level: 0,
|
||||
keywords: ["HTML5", "JavaScript", "PHP", "Python"],
|
||||
},
|
||||
{
|
||||
id: "r8c3y47vykausqrgmzwg5pur",
|
||||
visible: true,
|
||||
name: "Web Frameworks",
|
||||
description: "Intermediate",
|
||||
level: 0,
|
||||
keywords: ["React.js", "Angular", "Vue.js", "Laravel", "Django"],
|
||||
},
|
||||
{
|
||||
id: "b5l75aseexqv17quvqgh73fe",
|
||||
visible: true,
|
||||
name: "Tools",
|
||||
description: "Intermediate",
|
||||
level: 0,
|
||||
keywords: ["Webpack", "Git", "Jenkins", "Docker", "JIRA"],
|
||||
},
|
||||
],
|
||||
},
|
||||
custom: {},
|
||||
},
|
||||
metadata: {
|
||||
template: "glalie",
|
||||
layout: [
|
||||
[
|
||||
["summary", "experience", "education", "projects", "references"],
|
||||
[
|
||||
"profiles",
|
||||
"skills",
|
||||
"certifications",
|
||||
"interests",
|
||||
"languages",
|
||||
"awards",
|
||||
"volunteer",
|
||||
"publications",
|
||||
],
|
||||
],
|
||||
],
|
||||
css: {
|
||||
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
|
||||
visible: false,
|
||||
},
|
||||
page: {
|
||||
margin: 14,
|
||||
format: "a4",
|
||||
options: {
|
||||
breakLine: true,
|
||||
pageNumbers: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
background: "#ffffff",
|
||||
text: "#000000",
|
||||
primary: "#ca8a04",
|
||||
},
|
||||
typography: {
|
||||
font: {
|
||||
family: "Merriweather",
|
||||
subset: "latin",
|
||||
variants: ["regular"],
|
||||
size: 13,
|
||||
},
|
||||
lineHeight: 1.75,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: "",
|
||||
},
|
||||
};
|
||||
@ -268,6 +268,69 @@ export interface ResumeProjectsSettings {
|
||||
aiSelectableProjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ResumeProfile {
|
||||
basics?: {
|
||||
name?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
url?: string;
|
||||
summary?: string;
|
||||
headline?: string;
|
||||
location?: {
|
||||
address?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
region?: string;
|
||||
};
|
||||
profiles?: Array<{
|
||||
network?: string;
|
||||
username?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
sections?: {
|
||||
summary?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
content?: string;
|
||||
};
|
||||
skills?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
level: number;
|
||||
keywords: string[];
|
||||
visible: boolean;
|
||||
}>;
|
||||
};
|
||||
projects?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
summary: string;
|
||||
visible: boolean;
|
||||
keywords?: string[];
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user