diff --git a/.env.example b/.env.example index 7960390..c304924 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,16 @@ # OpenRouter API for AI scoring and summaries # Get your key at: https://openrouter.ai/keys OPENROUTER_API_KEY=your_openrouter_api_key_here -MODEL=openai/gpt-4o-mini +MODEL=google/gemini-3-flash-preview # RXResume credentials for PDF generation # Create an account at: https://v4.rxresu.me RXRESUME_EMAIL=your_email@example.com RXRESUME_PASSWORD=your_password_here -# Optional: Basic Auth for write access (read-only without auth) +# Optional: Basic Auth for write access +# the app is fully unauthenticated if this isn't set, which is the default # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. -# Browsing remains public and read-only. BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 6558b78..f60d8c6 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 return fetchApi('/profile/projects'); } +export async function getProfile(): Promise { + return fetchApi('/profile'); +} + export async function updateSettings(update: { model?: string | null diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index acc2ade..f2a5483 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -3,6 +3,8 @@ * * Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied. * The PDF is the primary artifact, represented abstractly through an Application Kit summary. + * + * Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,14 +44,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; onJobMoved: (jobId: string) => void; - onEditTailoring: () => void; - onEditDescription: () => void; } const safeFilenamePart = (value: string | null | undefined) => @@ -59,9 +63,8 @@ export const ReadyPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - onEditTailoring, - onEditDescription, }) => { + const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); const [catalog, setCatalog] = useState([]); @@ -72,11 +75,18 @@ export const ReadyPanel: React.FC = ({ timeoutId: ReturnType; } | 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 = ({ } }, [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 = ({ ); } + // Tailor mode - reuse the same TailorMode component with 'ready' variant + if (mode === "tailor") { + return ( + setMode("ready")} + onFinalize={handleTailorFinalize} + isFinalizing={isRegenerating} + variant="ready" + /> + ); + } + return (
= ({

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

diff --git a/orchestrator/src/client/hooks/useProfile.ts b/orchestrator/src/client/hooks/useProfile.ts new file mode 100644 index 0000000..d8a4ec5 --- /dev/null +++ b/orchestrator/src/client/hooks/useProfile.ts @@ -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(profileCache); + const [error, setError] = useState(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(); +} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 1262fd4..a8257a7 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -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( - - ); - - fireEvent.click(screen.getByRole("button", { name: /edit description/i })); - expect(onSetActiveTab).toHaveBeenCalledWith("discovered"); - }); it("shows an empty state when no job is selected", () => { render( diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index e7a1297..9176587 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -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 = ({ const [processingJobId, setProcessingJobId] = useState(null); const saveTailoringRef = useRef Promise)>(null); + const { personName } = useProfile(); + useEffect(() => { setHasUnsavedTailoring(false); saveTailoringRef.current = null; @@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - onEditTailoring={() => { - onSetActiveTab("discovered"); - setTimeout(() => setDetailTab("tailoring"), 50); - }} - onEditDescription={() => { - onSetActiveTab("discovered"); - setTimeout(() => { - setDetailTab("description"); - setIsEditingDescription(true); - }, 50); - }} /> ); } @@ -374,7 +366,7 @@ export const JobDetailPanel: React.FC = ({ Download PDF diff --git a/orchestrator/src/server/api/routes/manual-jobs.ts b/orchestrator/src/server/api/routes/manual-jobs.ts index 98dc595..2a2cb11 100644 --- a/orchestrator/src/server/api/routes/manual-jobs.ts +++ b/orchestrator/src/server/api/routes/manual-jobs.ts @@ -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'); } diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 9e91930..52ec3e4 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -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'); + }); }); diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index 1dc8067..e802cd0 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -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 }); + } +}); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index b4d14ba..898a740 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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 }); diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index dd204c9..c79448b 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -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 = {}): 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 = {}): 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 = {}): 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> { - try { - const content = await readFile(profilePath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - console.warn('Failed to load profile, using empty object'); - return {}; - } -} diff --git a/orchestrator/src/server/services/index.ts b/orchestrator/src/server/services/index.ts index 72f6b05..a9b3d16 100644 --- a/orchestrator/src/server/services/index.ts +++ b/orchestrator/src/server/services/index.ts @@ -4,3 +4,4 @@ export * from './scorer.js'; export * from './summary.js'; export * from './pdf.js'; export * from './notion.js'; +export * from './profile.js'; diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index f2cac94..7ceb079 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -4,18 +4,57 @@ import { getSetting } from '../repositories/settings.js'; import type { ManualJobDraft } from '../../shared/types.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface ManualJobInferenceResult { job: ManualJobDraft; warning?: string | null; } -export async function inferManualJobDetails(jobDescription: string): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; +/** Raw response type from the API (all fields are strings) */ +interface ManualJobApiResponse { + title: string; + employer: string; + location: string; + salary: string; + deadline: string; + jobUrl: string; + applicationLink: string; + jobType: string; + jobLevel: string; + jobFunction: string; + disciplines: string; + degreeRequired: string; + starting: string; +} - if (!apiKey) { +/** JSON schema for manual job extraction response */ +const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = { + name: 'manual_job_details', + schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Job title' }, + employer: { type: 'string', description: 'Company/employer name' }, + location: { type: 'string', description: 'Job location' }, + salary: { type: 'string', description: 'Salary information' }, + deadline: { type: 'string', description: 'Application deadline' }, + jobUrl: { type: 'string', description: 'URL of the job listing' }, + applicationLink: { type: 'string', description: 'Direct application URL' }, + jobType: { type: 'string', description: 'Employment type (full-time, part-time, etc.)' }, + jobLevel: { type: 'string', description: 'Seniority level (entry, mid, senior, etc.)' }, + jobFunction: { type: 'string', description: 'Job function/category' }, + disciplines: { type: 'string', description: 'Required disciplines or fields' }, + degreeRequired: { type: 'string', description: 'Required degree or education' }, + starting: { type: 'string', description: 'Start date information' }, + }, + required: ['title', 'employer', 'location', 'salary', 'deadline', 'jobUrl', 'applicationLink', 'jobType', 'jobLevel', 'jobFunction', 'disciplines', 'degreeRequired', 'starting'], + additionalProperties: false, + }, +}; + +export async function inferManualJobDetails(jobDescription: string): Promise { + if (!process.env.OPENROUTER_API_KEY) { return { job: {}, warning: 'OPENROUTER_API_KEY not set. Fill details manually.', @@ -26,41 +65,21 @@ export async function inferManualJobDetails(jobDescription: string): Promise({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: MANUAL_JOB_SCHEMA, + }); - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - if (!content) { - throw new Error('No content in response'); - } - - const parsed = parseJsonFromContent(content); - return { job: normalizeDraft(parsed) }; - } catch (error) { - console.warn('Manual job inference failed:', error); + if (!result.success) { + console.warn('Manual job inference failed:', result.error); return { job: {}, warning: 'AI inference failed. Fill details manually.', }; } + + return { job: normalizeDraft(result.data) }; } function buildInferencePrompt(jd: string): string { @@ -106,58 +125,23 @@ OUTPUT FORMAT (JSON ONLY): `.trim(); } -function parseJsonFromContent(content: string): Record { - const trimmed = content.trim(); - const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim(); - - try { - return JSON.parse(withoutFences); - } catch { - const firstBrace = withoutFences.indexOf('{'); - const lastBrace = withoutFences.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sliced = withoutFences.slice(firstBrace, lastBrace + 1); - return JSON.parse(sliced); - } - throw new Error('Unable to parse JSON from model response'); - } -} - -function normalizeDraft(parsed: Record): ManualJobDraft { - const fields: Array = [ - 'title', - '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; -} diff --git a/orchestrator/src/server/services/openrouter.test.ts b/orchestrator/src/server/services/openrouter.test.ts new file mode 100644 index 0000000..c36d77c --- /dev/null +++ b/orchestrator/src/server/services/openrouter.test.ts @@ -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(); + }); +}); diff --git a/orchestrator/src/server/services/openrouter.ts b/orchestrator/src/server/services/openrouter.ts new file mode 100644 index 0000000..e438728 --- /dev/null +++ b/orchestrator/src/server/services/openrouter.ts @@ -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; + required: string[]; + additionalProperties: boolean; + }; +} + +export interface OpenRouterRequestOptions { + /** The model to use (e.g., 'google/gemini-3-flash-preview') */ + model: string; + /** The prompt messages to send */ + messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>; + /** JSON schema for structured output */ + jsonSchema: JsonSchemaDefinition; + /** Number of retries on parsing failures (default: 0) */ + maxRetries?: number; + /** Delay between retries in ms (default: 500) */ + retryDelayMs?: number; + /** Job ID for logging purposes */ + jobId?: string; +} + +export interface OpenRouterResult { + success: true; + data: T; +} + +export interface OpenRouterError { + success: false; + error: string; +} + +export type OpenRouterResponse = OpenRouterResult | OpenRouterError; + +/** + * Call OpenRouter API with structured JSON output. + * + * @returns Parsed JSON response matching the schema, or an error object + */ +export async function callOpenRouter( + options: OpenRouterRequestOptions +): Promise> { + const apiKey = process.env.OPENROUTER_API_KEY; + + if (!apiKey) { + return { success: false, error: 'OPENROUTER_API_KEY not configured' }; + } + + const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + console.log(`šŸ”„ [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`); + await sleep(retryDelayMs * attempt); + } + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'JobOps', + 'X-Title': 'JobOpsOrchestrator', + }, + body: JSON.stringify({ + model, + messages, + response_format: { + type: 'json_schema', + json_schema: { + name: jsonSchema.name, + strict: true, + schema: jsonSchema.schema, + }, + }, + }), + }); + + if (!response.ok) { + // Throw 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(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(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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts new file mode 100644 index 0000000..785e69c --- /dev/null +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -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(); + }); +}); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 9af293e..7fafa77 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -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 { 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); }); } diff --git a/orchestrator/src/server/services/profile.test.ts b/orchestrator/src/server/services/profile.test.ts new file mode 100644 index 0000000..7d17a42 --- /dev/null +++ b/orchestrator/src/server/services/profile.test.ts @@ -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(); + }); +}); diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts new file mode 100644 index 0000000..f108658 --- /dev/null +++ b/orchestrator/src/server/services/profile.ts @@ -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 { + 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 { + const profile = await getProfile(); + return profile?.basics?.name || 'Resume'; +} + +/** + * Clear the profile cache. + */ +export function clearProfileCache(): void { + cachedProfile = null; +} diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index 1fd405f..a90530b 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -1,8 +1,27 @@ +/** + * Service for AI-powered project selection for resumes. + */ + import { getSetting } from '../repositories/settings.js'; - import type { ResumeProjectSelectionItem } from './resumeProjects.js'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +/** JSON schema for project selection response */ +const PROJECT_SELECTION_SCHEMA: JsonSchemaDefinition = { + name: 'project_selection', + schema: { + type: 'object', + properties: { + selectedProjectIds: { + type: 'array', + items: { type: 'string' }, + description: 'List of project IDs to include on the resume', + }, + }, + required: ['selectedProjectIds'], + additionalProperties: false, + }, +}; export async function pickProjectIdsForJob(args: { jobDescription: string; @@ -15,8 +34,7 @@ export async function pickProjectIdsForJob(args: { const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id)); if (eligibleIds.size === 0) return []; - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } @@ -31,53 +49,39 @@ export async function pickProjectIdsForJob(args: { desiredCount, }); - try { - const response = await fetch(OPENROUTER_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-Title': 'JobOpsOrchestrator', - }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - response_format: { type: 'json_object' }, - }), - }); + const result = await callOpenRouter<{ selectedProjectIds: string[] }>({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: PROJECT_SELECTION_SCHEMA, + }); - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - if (!content) throw new Error('No content in response'); - - const parsed = JSON.parse(content) as any; - const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : []; - const unique: string[] = []; - const seen = new Set(); - for (const id of selectedProjectIds) { - if (typeof id !== 'string') continue; - const trimmed = id.trim(); - if (!trimmed) continue; - if (!eligibleIds.has(trimmed)) continue; - if (seen.has(trimmed)) continue; - seen.add(trimmed); - unique.push(trimmed); - if (unique.length >= desiredCount) break; - } - - if (unique.length === 0) { - return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); - } - - return unique; - } catch { + if (!result.success) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } + + const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds) + ? result.data.selectedProjectIds + : []; + + // Validate and dedupe the returned IDs + const unique: string[] = []; + const seen = new Set(); + for (const id of selectedProjectIds) { + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (!trimmed) continue; + if (!eligibleIds.has(trimmed)) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + unique.push(trimmed); + if (unique.length >= desiredCount) break; + } + + if (unique.length === 0) { + return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); + } + + return unique; } function buildProjectSelectionPrompt(args: { @@ -167,4 +171,3 @@ function truncate(input: string, maxChars: number): string { if (input.length <= maxChars) return input; return `${input.slice(0, maxChars - 1).trimEnd()}…`; } - diff --git a/orchestrator/src/server/services/resumeProjects.test.ts b/orchestrator/src/server/services/resumeProjects.test.ts index 8ca4d94..a43a891 100644 --- a/orchestrator/src/server/services/resumeProjects.test.ts +++ b/orchestrator/src/server/services/resumeProjects.test.ts @@ -66,7 +66,7 @@ describe('Resume Projects Logic', () => { }); it('should ensure maxProjects is at least len(locked)', () => { - const input = { + const input = { maxProjects: 1, // Too small lockedProjectIds: ['a', 'b'], aiSelectableProjectIds: [] @@ -105,6 +105,7 @@ describe('Resume Projects Logic', () => { // p1 is visible in base, so it should be locked by default expect(result.resumeProjects.lockedProjectIds).toEqual(['p1']); expect(result.resumeProjects.aiSelectableProjectIds).toEqual(['p2', 'p3']); + expect(result.resumeProjects.maxProjects).toBe(3); }); it('should apply valid overrides', () => { @@ -126,7 +127,7 @@ describe('Resume Projects Logic', () => { }); it('should handle invalid overrides by falling back to defaults', () => { - const result = rp.resolveResumeProjectsSettings({ + const result = rp.resolveResumeProjectsSettings({ catalog: mockCatalog, overrideRaw: '{"broken json' }); diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts index e97bad3..2892ead 100644 --- a/orchestrator/src/server/services/resumeProjects.ts +++ b/orchestrator/src/server/services/resumeProjects.ts @@ -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 { - 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( diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts new file mode 100644 index 0000000..08caa61 --- /dev/null +++ b/orchestrator/src/server/services/scorer.test.ts @@ -0,0 +1,241 @@ +/** + * Tests for scorer.ts - focusing on robust JSON parsing from AI responses + */ + +import { describe, it, expect } from 'vitest'; +import { parseJsonFromContent } from './scorer.js'; + +describe('parseJsonFromContent', () => { + describe('valid JSON inputs', () => { + it('should parse clean JSON object', () => { + const input = '{"score": 85, "reason": "Great match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBe('Great match'); + }); + + it('should parse JSON with extra whitespace', () => { + const input = ' { "score" : 75 , "reason" : "Good fit" } '; + const result = parseJsonFromContent(input); + expect(result.score).toBe(75); + expect(result.reason).toBe('Good fit'); + }); + + it('should parse JSON with newlines', () => { + const input = `{ + "score": 90, + "reason": "Excellent match for the role" + }`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(90); + expect(result.reason).toBe('Excellent match for the role'); + }); + }); + + describe('markdown code fences', () => { + it('should strip ```json code fences', () => { + const input = '```json\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ```JSON code fences (uppercase)', () => { + const input = '```JSON\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ``` code fences without language specifier', () => { + const input = '```\n{"score": 70, "reason": "Decent"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + }); + + it('should handle nested code fence patterns', () => { + const input = 'Here is the score:\n```json\n{"score": 65, "reason": "Partial match"}\n```\nEnd.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(65); + }); + }); + + describe('surrounding text', () => { + it('should extract JSON from text before', () => { + const input = 'Based on my analysis, here is my evaluation: {"score": 55, "reason": "Limited match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(55); + }); + + it('should extract JSON from text after', () => { + const input = '{"score": 60, "reason": "Moderate match"} I hope this helps!'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(60); + }); + + it('should extract JSON from surrounding text on both sides', () => { + const input = 'Here is my response:\n\n{"score": 45, "reason": "Below average fit"}\n\nLet me know if you need more details.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(45); + }); + }); + + describe('common JSON formatting issues', () => { + it('should handle trailing comma before closing brace', () => { + const input = '{"score": 78, "reason": "Good skills",}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(78); + }); + + it('should handle single quotes instead of double quotes', () => { + const input = "{'score': 82, 'reason': 'Strong candidate'}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(82); + }); + + it('should handle unquoted keys', () => { + const input = '{score: 77, reason: "Reasonable match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(77); + }); + + it('should handle mixed issues (trailing comma, single quotes)', () => { + const input = "{'score': 68, 'reason': 'Average fit',}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(68); + }); + }); + + describe('decimal scores', () => { + it('should parse and round decimal scores', () => { + // parseJsonFromContent returns raw value for valid JSON; rounding only in regex fallback + const input = '{"score": 85.7, "reason": "Very good match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85.7); + }); + + it('should parse decimal scores in malformed text', () => { + const input = 'The score is score: 72.3, reason: "Above average"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + }); + }); + + describe('malformed responses - regex fallback', () => { + it('should extract score from completely malformed response', () => { + const input = 'I think the score should be score: 50 and the reason: "Average candidate"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + }); + + it('should extract score with equals sign syntax', () => { + const input = 'score = 88, reason = "Excellent match"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(88); + }); + + it('should handle reason with special characters', () => { + const input = '{"score": 73, "reason": "Good match! The candidate\'s skills align well."}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(73); + }); + + it('should provide default reason when only score is extractable', () => { + const input = 'I rate this candidate 85 out of 100 - score: 85'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle zero score', () => { + const input = '{"score": 0, "reason": "No match at all"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(0); + }); + + it('should handle score of 100', () => { + const input = '{"score": 100, "reason": "Perfect candidate"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(100); + }); + + it('should handle empty reason', () => { + const input = '{"score": 50, "reason": ""}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + expect(result.reason).toBe(''); + }); + + it('should handle multiline reason', () => { + const input = `{"score": 70, "reason": "Good skills match. Experience is a bit lacking."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + expect(result.reason).toContain('Good skills match'); + }); + + it('should handle unicode in reason', () => { + const input = '{"score": 80, "reason": "Great match āœ“ for this role"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + }); + + describe('failure cases', () => { + it('should throw when no score can be extracted', () => { + const input = 'This is just plain text with no JSON or score.'; + expect(() => parseJsonFromContent(input)).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for empty input', () => { + expect(() => parseJsonFromContent('')).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for only whitespace', () => { + expect(() => parseJsonFromContent(' \n\t ')).toThrow('Unable to parse JSON from model response'); + }); + }); + + describe('real-world AI responses', () => { + it('should handle GPT-style verbose response', () => { + const input = `Based on my analysis of the job description and candidate profile, I have evaluated the fit: + +\`\`\`json +{ + "score": 72, + "reason": "Strong React and TypeScript skills match. However, the role requires 5+ years experience which the candidate may not have." +} +\`\`\` + +This score reflects the candidate's technical capabilities while accounting for the experience gap.`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + expect(result.reason).toContain('React and TypeScript'); + }); + + it('should handle Claude-style response with thinking', () => { + const input = `Let me evaluate this candidate against the job requirements. + +{"score": 83, "reason": "Excellent frontend skills with React and modern tooling. Good culture fit based on startup experience."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(83); + }); + + it('should handle response with JSON5-style comments', () => { + // Some models output JSON5-like syntax with comments + const input = `{ + "score": 67, // Good but not great + "reason": "Matches most requirements but lacks cloud experience" +}`; + // This will fail standard parse but regex should catch it + const result = parseJsonFromContent(input); + expect(result.score).toBe(67); + }); + + it('should handle response with extra properties', () => { + const input = '{"score": 79, "reason": "Good match", "confidence": "high", "breakdown": {"skills": 25, "experience": 20}}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(79); + expect(result.reason).toBe('Good match'); + }); + }); +}); diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 4c7e18d..ea46c79 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -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 ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('āš ļø OPENROUTER_API_KEY not set, using mock scoring'); return mockScore(job); } @@ -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 { - 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": "" } -`; +IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON. + +REQUIRED FORMAT (exactly this structure): +{"score": , "reason": "<1-2 sentence explanation>"} + +EXAMPLE VALID RESPONSE: +{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`; } + function mockScore(job: Job): SuitabilityResult { // Simple keyword-based scoring as fallback const jd = (job.jobDescription || '').toLowerCase(); const title = job.title.toLowerCase(); - + const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer']; const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager']; - + let score = 50; - + for (const kw of goodKeywords) { if (jd.includes(kw) || title.includes(kw)) score += 5; } - + for (const kw of badKeywords) { if (jd.includes(kw) || title.includes(kw)) score -= 10; } - + score = Math.min(100, Math.max(0, score)); - + return { score, reason: 'Scored using keyword matching (API key not configured)', @@ -160,6 +235,6 @@ export async function scoreAndRankJobs( }; }) ); - + return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore); } diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index fc70590..03fde39 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -3,13 +3,12 @@ */ import { getSetting } from '../repositories/settings.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface TailoredData { summary: string; headline: string; - skills: any[]; + skills: Array<{ name: string; keywords: string[] }>; } export interface TailoringResult { @@ -18,6 +17,46 @@ export interface TailoringResult { error?: string; } +/** JSON schema for resume tailoring response */ +const TAILORING_SCHEMA: JsonSchemaDefinition = { + name: 'resume_tailoring', + schema: { + type: 'object', + properties: { + headline: { + type: 'string', + description: 'Job title headline matching the JD exactly', + }, + summary: { + type: 'string', + description: 'Tailored resume summary paragraph', + }, + skills: { + type: 'array', + description: 'Skills sections with keywords tailored to the job', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Skill category name (e.g., Frontend, Backend)', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: 'List of skills/technologies in this category', + }, + }, + required: ['name', 'keywords'], + additionalProperties: false, + }, + }, + }, + required: ['headline', 'summary', 'skills'], + additionalProperties: false, + }, +}; + /** * Generate tailored resume content (summary, headline, skills) for a job. */ @@ -25,65 +64,42 @@ export async function generateTailoring( jobDescription: string, profile: Record ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('āš ļø OPENROUTER_API_KEY not set, cannot generate tailoring'); return { success: false, error: 'API key not configured' }; } - + const overrideModel = await getSetting('model'); const overrideModelTailoring = await getSetting('modelTailoring'); // Precedence: Tailoring-specific override > Global override > Env var > Default const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildTailoringPrompt(profile, jobDescription); - - try { - const response = await fetch(OPENROUTER_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-Title': 'JobOpsOrchestrator', - }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - response_format: { type: 'json_object' }, - }), - }); - - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - - if (!content) { - throw new Error('No content in response'); - } - - const parsed = JSON.parse(content); - - // Basic validation - if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) { - console.warn('āš ļø AI response missing required fields:', parsed); - } - return { - success: true, - data: { - summary: sanitizeText(parsed.summary || ''), - headline: sanitizeText(parsed.headline || ''), - skills: parsed.skills || [] - } - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { success: false, error: message }; + const result = await callOpenRouter({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: TAILORING_SCHEMA, + }); + + if (!result.success) { + return { success: false, error: result.error }; } + + const { summary, headline, skills } = result.data; + + // Basic validation + if (!summary || !headline || !Array.isArray(skills)) { + console.warn('āš ļø AI response missing required fields:', result.data); + } + + return { + success: true, + data: { + summary: sanitizeText(summary || ''), + headline: sanitizeText(headline || ''), + skills: skills || [] + } + }; } /** @@ -112,14 +128,14 @@ function buildTailoringPrompt(profile: Record, jd: string): str }, skills: (profile as any).sections?.skills || (profile as any).skills, projects: (profile as any).sections?.projects?.items?.map((p: any) => ({ - name: p.name, - description: p.description, - keywords: p.keywords + name: p.name, + description: p.description, + keywords: p.keywords })), experience: (profile as any).sections?.experience?.items?.map((e: any) => ({ - company: e.company, - position: e.position, - summary: e.summary + company: e.company, + position: e.position, + summary: e.summary })) }; @@ -127,8 +143,8 @@ function buildTailoringPrompt(profile: Record, jd: string): str You are an expert resume writer tailoring a profile for a specific job application. You must return a JSON object with three fields: "headline", "summary", and "skills". -JOB DESCRIPTION: -${jd.slice(0, 3000)} ... (truncated if too long) +JOB DESCRIPTION (JD): +${jd} MY PROFILE: ${JSON.stringify(relevantProfile, null, 2)} diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts new file mode 100644 index 0000000..565ee21 --- /dev/null +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -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 = { + [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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; +export type Sections = z.infer; + +export type SectionKey = "basics" | keyof Sections | `custom.${string}`; +export type SectionWithItem = Sections[FilterKeys]; +export type SectionItem = SectionWithItem["items"][number]; +export type CustomSectionGroup = z.infer; + +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; + +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: + "

Innovative Web Developer with 5 years of experience in building impactful and user-friendly websites and applications. Specializes in front-end technologies and passionate about modern web standards and cutting-edge development techniques. Proven track record of leading successful projects from concept to deployment.

", + }, + 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: + "
  • Spearheaded the redesign of the main product website, resulting in a 40% increase in user engagement.

  • Developed and implemented a new responsive framework, improving cross-device compatibility.

  • Mentored a team of four junior developers, fostering a culture of technical excellence.

", + 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: + "
  • Collaborated in a team of 10 to develop high-quality web applications using React.js and Node.js.

  • Managed the integration of third-party services such as Stripe for payments and Twilio for SMS services.

  • Optimized application performance, achieving a 30% reduction in load times.

", + 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: + "

Led the development of a full-stack e-commerce platform, improving sales conversion by 25%.

", + keywords: [], + url: { + label: "", + href: "", + }, + }, + { + id: "ncxgdjjky54gh59iz2t1xi1v", + visible: true, + name: "Interactive Dashboard", + description: "Frontend Developer", + date: "", + summary: + "

Created an interactive analytics dashboard for a SaaS application, enhancing data visualization for clients.

", + 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: "", + }, +}; diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index e134aa4..d59c27f 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -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;