Merge pull request #14 from DaKheera47/self-hosting-improvement

Self hosting improvement
This commit is contained in:
Shaheer Sarfaraz 2026-01-21 16:43:18 +00:00 committed by GitHub
commit a4ed912de1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2408 additions and 367 deletions

View File

@ -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=

View File

@ -20,6 +20,7 @@ import type {
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
VisaSponsor,
ResumeProfile,
} from '../../shared/types';
import { trackEvent } from "@/lib/analytics";
@ -174,6 +175,10 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
}
export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile');
}
export async function updateSettings(update: {
model?: string | null

View File

@ -3,6 +3,8 @@
*
* Designed for a single, fast, repeatable workflow: verify download apply mark applied.
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
*
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
@ -42,14 +44,16 @@ import {
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
import * as api from "../api";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { useProfile } from "../hooks/useProfile";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
type PanelMode = "ready" | "tailor";
interface ReadyPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
onEditTailoring: () => void;
onEditDescription: () => void;
}
const safeFilenamePart = (value: string | null | undefined) =>
@ -59,9 +63,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
onEditTailoring,
onEditDescription,
}) => {
const [mode, setMode] = useState<PanelMode>("ready");
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
@ -72,11 +75,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
timeoutId: ReturnType<typeof setTimeout>;
} | null>(null);
const { personName } = useProfile();
// Load project catalog once
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
}, []);
// Reset mode when job changes
useEffect(() => {
setMode("ready");
}, [job?.id]);
// Compute derived values
const pdfHref = job
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
@ -198,6 +208,23 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
}
}, [job]);
// Handler for regenerating PDF after tailoring edits
const handleTailorFinalize = useCallback(async () => {
if (!job) return;
try {
setIsRegenerating(true);
await api.generateJobPdf(job.id);
toast.success("PDF regenerated");
await onJobUpdated();
setMode("ready");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message);
} finally {
setIsRegenerating(false);
}
}, [job, onJobUpdated]);
// Empty state
if (!job) {
return (
@ -213,6 +240,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
);
}
// Tailor mode - reuse the same TailorMode component with 'ready' variant
if (mode === "tailor") {
return (
<TailorMode
job={job}
onBack={() => setMode("ready")}
onFinalize={handleTailorFinalize}
isFinalizing={isRegenerating}
variant="ready"
/>
);
}
return (
<div className="flex flex-col h-full">
<JobHeader
@ -242,7 +282,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
<a
href={pdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
>
<Download className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Download PDF</span>
@ -332,7 +372,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
{/* Fix/Edit actions */}
<DropdownMenuItem onSelect={onEditTailoring}>
<DropdownMenuItem onSelect={() => setMode("tailor")}>
<Edit2 className="mr-2 h-4 w-4" />
Edit tailoring
</DropdownMenuItem>
@ -345,11 +385,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onEditDescription}>
<Edit2 className="mr-2 h-4 w-4" />
Edit job description
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Utility actions */}

View File

@ -15,6 +15,8 @@ interface TailorModeProps {
onBack: () => void;
onFinalize: () => void;
isFinalizing: boolean;
/** Variant controls the finalize button text. Default is 'discovered'. */
variant?: 'discovered' | 'ready';
}
export const TailorMode: React.FC<TailorModeProps> = ({
@ -22,6 +24,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onBack,
onFinalize,
isFinalizing,
variant = 'discovered',
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
@ -274,7 +277,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
<div className='space-y-2'>
{!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'>
Add a summary and select at least one project to finalize.
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
</p>
)}
<Button
@ -285,17 +288,19 @@ export const TailorMode: React.FC<TailorModeProps> = ({
{isFinalizing ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Finalizing & generating PDF...
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
</>
) : (
<>
<Check className='mr-2 h-4 w-4' />
Finalize & Move to Ready
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
</>
)}
</Button>
<p className='text-[10px] text-center text-muted-foreground/70'>
This will generate your tailored PDF and move the job to Ready.
{variant === 'ready'
? 'This will save your changes and regenerate the tailored PDF.'
: 'This will generate your tailored PDF and move the job to Ready.'}
</p>
</div>
</div>

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import * as api from '../api';
import type { ResumeProfile } from '../../shared/types';
let profileCache: ResumeProfile | null = null;
let profileError: Error | null = null;
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
let isFetching = false;
/**
* Hook to get the full profile data from base.json.
* Caches the result to avoid re-fetching.
*/
export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError);
useEffect(() => {
if (profileCache) {
setProfile(profileCache);
}
if (profileError) {
setError(profileError);
}
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
setProfile(newProfile);
setError(newError);
};
subscribers.add(handleUpdate);
if (!profileCache && !isFetching) {
isFetching = true;
profileError = null;
api.getProfile()
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach(sub => sub(profileCache, null));
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
throw profileError;
} finally {
isFetching = false;
}
};
return {
profile,
error,
isLoading: !profile && isFetching && !error,
personName: profile?.basics?.name || 'Resume',
refreshProfile,
};
}
/** @internal For testing only */
export function _resetProfileCache() {
profileCache = null;
profileError = null;
isFetching = false;
subscribers.clear();
}

View File

@ -66,6 +66,7 @@ vi.mock("../../api", () => ({
generateJobPdf: vi.fn(),
markAsApplied: vi.fn(),
skipJob: vi.fn(),
getProfile: vi.fn().mockResolvedValue({}),
}));
vi.mock("sonner", () => ({
@ -159,23 +160,7 @@ describe("JobDetailPanel", () => {
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
});
it("wires ready panel edit actions back to the page", () => {
const onSetActiveTab = vi.fn();
render(
<JobDetailPanel
activeTab="ready"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={onSetActiveTab}
/>
);
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
});
it("shows an empty state when no job is selected", () => {
render(

View File

@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml }
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor";
import { useProfile } from "../../hooks/useProfile";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import type { FilterTab } from "./constants";
@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
const { personName } = useProfile();
useEffect(() => {
setHasUnsavedTailoring(false);
saveTailoringRef.current = null;
@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
onEditTailoring={() => {
onSetActiveTab("discovered");
setTimeout(() => setDetailTab("tailoring"), 50);
}}
onEditDescription={() => {
onSetActiveTab("discovered");
setTimeout(() => {
setDetailTab("description");
setIsEditingDescription(true);
}, 50);
}}
/>
);
}
@ -374,7 +366,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<DropdownMenuItem asChild>
<a
href={selectedPdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
>
<FileText className="mr-2 h-4 w-4" />
Download PDF

View File

@ -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');
}

View File

@ -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');
});
});

View File

@ -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 });
}
});

View File

@ -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 });

View File

@ -16,6 +16,7 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
import { scoreJobSuitability } from '../services/scorer.js';
import { generateTailoring } from '../services/summary.js';
import { generatePdf } from '../services/pdf.js';
import { getProfile } from '../services/profile.js';
import { getSetting } from '../repositories/settings.js';
import { pickProjectIdsForJob } from '../services/projectSelection.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js';
@ -112,7 +113,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
try {
// Step 1: Load profile
console.log('\n📋 Loading profile...');
const profile = await loadProfile(mergedConfig.profilePath);
const profile = await getProfile(mergedConfig.profilePath);
// Step 2: Run crawler
console.log('\n🕷 Running crawler...');
@ -346,7 +347,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
// Process job (Generate Summary + PDF)
// We catch errors here to ensure one failure doesn't stop the whole batch
const result = await processJob(job.id);
const result = await processJob(job.id, { profilePath: mergedConfig.profilePath });
if (result.success) {
processedCount++;
@ -413,12 +414,17 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
}
}
export type ProcessJobOptions = {
force?: boolean;
profilePath?: string;
};
/**
* Step 1: Generate AI summary and suggest projects.
*/
export async function summarizeJob(
jobId: string,
options?: { force?: boolean }
options?: ProcessJobOptions
): Promise<{
success: boolean;
error?: string;
@ -429,7 +435,7 @@ export async function summarizeJob(
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: 'Job not found' };
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
const profile = await getProfile(options?.profilePath);
// 1. Generate Summary & Tailoring
let tailoredSummary = job.tailoredSummary;
@ -490,7 +496,8 @@ export async function summarizeJob(
* Step 2: Generate PDF using current summary and project selection.
*/
export async function generateFinalPdf(
jobId: string
jobId: string,
options?: ProcessJobOptions
): Promise<{
success: boolean;
error?: string;
@ -512,7 +519,7 @@ export async function generateFinalPdf(
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
},
job.jobDescription || '',
DEFAULT_PROFILE_PATH,
options?.profilePath || DEFAULT_PROFILE_PATH,
job.selectedProjectIds
);
@ -539,7 +546,7 @@ export async function generateFinalPdf(
*/
export async function processJob(
jobId: string,
options?: { force?: boolean }
options?: ProcessJobOptions
): Promise<{
success: boolean;
error?: string;
@ -550,7 +557,7 @@ export async function processJob(
if (!sumResult.success) return sumResult;
// Step 2: Generate PDF
const pdfResult = await generateFinalPdf(jobId);
const pdfResult = await generateFinalPdf(jobId, options);
return pdfResult;
} catch (error) {
@ -566,15 +573,3 @@ export function getPipelineStatus(): { isRunning: boolean } {
return { isRunning: isPipelineRunning };
}
/**
* Load the user profile from JSON file.
*/
async function loadProfile(profilePath: string): Promise<Record<string, unknown>> {
try {
const content = await readFile(profilePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
console.warn('Failed to load profile, using empty object');
return {};
}
}

View File

@ -4,3 +4,4 @@ export * from './scorer.js';
export * from './summary.js';
export * from './pdf.js';
export * from './notion.js';
export * from './profile.js';

View File

@ -4,18 +4,57 @@
import { getSetting } from '../repositories/settings.js';
import type { ManualJobDraft } from '../../shared/types.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
export interface ManualJobInferenceResult {
job: ManualJobDraft;
warning?: string | null;
}
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
/** Raw response type from the API (all fields are strings) */
interface ManualJobApiResponse {
title: string;
employer: string;
location: string;
salary: string;
deadline: string;
jobUrl: string;
applicationLink: string;
jobType: string;
jobLevel: string;
jobFunction: string;
disciplines: string;
degreeRequired: string;
starting: string;
}
if (!apiKey) {
/** JSON schema for manual job extraction response */
const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
name: 'manual_job_details',
schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Job title' },
employer: { type: 'string', description: 'Company/employer name' },
location: { type: 'string', description: 'Job location' },
salary: { type: 'string', description: 'Salary information' },
deadline: { type: 'string', description: 'Application deadline' },
jobUrl: { type: 'string', description: 'URL of the job listing' },
applicationLink: { type: 'string', description: 'Direct application URL' },
jobType: { type: 'string', description: 'Employment type (full-time, part-time, etc.)' },
jobLevel: { type: 'string', description: 'Seniority level (entry, mid, senior, etc.)' },
jobFunction: { type: 'string', description: 'Job function/category' },
disciplines: { type: 'string', description: 'Required disciplines or fields' },
degreeRequired: { type: 'string', description: 'Required degree or education' },
starting: { type: 'string', description: 'Start date information' },
},
required: ['title', 'employer', 'location', 'salary', 'deadline', 'jobUrl', 'applicationLink', 'jobType', 'jobLevel', 'jobFunction', 'disciplines', 'degreeRequired', 'starting'],
additionalProperties: false,
},
};
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
if (!process.env.OPENROUTER_API_KEY) {
return {
job: {},
warning: 'OPENROUTER_API_KEY not set. Fill details manually.',
@ -26,41 +65,21 @@ export async function inferManualJobDetails(jobDescription: string): Promise<Man
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildInferencePrompt(jobDescription);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
});
const result = await callOpenRouter<ManualJobApiResponse>({
model,
messages: [{ role: 'user', content: prompt }],
jsonSchema: MANUAL_JOB_SCHEMA,
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content in response');
}
const parsed = parseJsonFromContent(content);
return { job: normalizeDraft(parsed) };
} catch (error) {
console.warn('Manual job inference failed:', error);
if (!result.success) {
console.warn('Manual job inference failed:', result.error);
return {
job: {},
warning: 'AI inference failed. Fill details manually.',
};
}
return { job: normalizeDraft(result.data) };
}
function buildInferencePrompt(jd: string): string {
@ -106,58 +125,23 @@ OUTPUT FORMAT (JSON ONLY):
`.trim();
}
function parseJsonFromContent(content: string): Record<string, unknown> {
const trimmed = content.trim();
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
try {
return JSON.parse(withoutFences);
} catch {
const firstBrace = withoutFences.indexOf('{');
const lastBrace = withoutFences.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
const sliced = withoutFences.slice(firstBrace, lastBrace + 1);
return JSON.parse(sliced);
}
throw new Error('Unable to parse JSON from model response');
}
}
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
const fields: Array<keyof ManualJobDraft> = [
'title',
'employer',
'location',
'salary',
'deadline',
'jobUrl',
'applicationLink',
'jobType',
'jobLevel',
'jobFunction',
'disciplines',
'degreeRequired',
'starting',
];
function normalizeDraft(parsed: ManualJobApiResponse): ManualJobDraft {
const out: ManualJobDraft = {};
for (const field of fields) {
const value = toCleanString(parsed[field]);
if (value) out[field] = value;
}
// Map each field, only including non-empty strings
if (parsed.title?.trim()) out.title = parsed.title.trim();
if (parsed.employer?.trim()) out.employer = parsed.employer.trim();
if (parsed.location?.trim()) out.location = parsed.location.trim();
if (parsed.salary?.trim()) out.salary = parsed.salary.trim();
if (parsed.deadline?.trim()) out.deadline = parsed.deadline.trim();
if (parsed.jobUrl?.trim()) out.jobUrl = parsed.jobUrl.trim();
if (parsed.applicationLink?.trim()) out.applicationLink = parsed.applicationLink.trim();
if (parsed.jobType?.trim()) out.jobType = parsed.jobType.trim();
if (parsed.jobLevel?.trim()) out.jobLevel = parsed.jobLevel.trim();
if (parsed.jobFunction?.trim()) out.jobFunction = parsed.jobFunction.trim();
if (parsed.disciplines?.trim()) out.disciplines = parsed.disciplines.trim();
if (parsed.degreeRequired?.trim()) out.degreeRequired = parsed.degreeRequired.trim();
if (parsed.starting?.trim()) out.starting = parsed.starting.trim();
return out;
}
function toCleanString(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}

View File

@ -0,0 +1,199 @@
/**
* Tests for the shared OpenRouter API helper.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { callOpenRouter, parseJsonContent, type JsonSchemaDefinition } from './openrouter.js';
// Mock fetch globally
const originalFetch = global.fetch;
const testSchema: JsonSchemaDefinition = {
name: 'test_schema',
schema: {
type: 'object',
properties: {
value: { type: 'string', description: 'A test value' },
count: { type: 'integer', description: 'A test count' },
},
required: ['value', 'count'],
additionalProperties: false,
},
};
describe('callOpenRouter', () => {
beforeEach(() => {
process.env.OPENROUTER_API_KEY = 'test-api-key';
global.fetch = vi.fn();
});
afterEach(() => {
delete process.env.OPENROUTER_API_KEY;
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('should return error when API key is not set', async () => {
delete process.env.OPENROUTER_API_KEY;
const result = await callOpenRouter({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
jsonSchema: testSchema,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('API_KEY');
}
});
it('should return parsed data on successful response', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify({ value: 'hello', count: 42 }) } }],
}),
} as Response);
const result = await callOpenRouter<{ value: string; count: number }>({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
jsonSchema: testSchema,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.value).toBe('hello');
expect(result.data.count).toBe(42);
}
});
it('should handle API errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
} as Response);
const result = await callOpenRouter({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
jsonSchema: testSchema,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('500');
}
});
it('should handle empty response content', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: '' } }],
}),
} as Response);
const result = await callOpenRouter({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
jsonSchema: testSchema,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('No content');
}
});
it('should include json_schema in request body', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: '{"value": "test", "count": 1}' } }],
}),
} as Response);
await callOpenRouter({
model: 'test-model',
messages: [{ role: 'user', content: 'test prompt' }],
jsonSchema: testSchema,
});
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(fetchCall[1]?.body as string);
expect(body.response_format.type).toBe('json_schema');
expect(body.response_format.json_schema.name).toBe('test_schema');
expect(body.response_format.json_schema.strict).toBe(true);
});
it('should retry on parsing failures when maxRetries is set', async () => {
let callCount = 0;
vi.mocked(global.fetch).mockImplementation(async () => {
callCount++;
if (callCount < 3) {
return {
ok: true,
json: async () => ({
choices: [{ message: { content: 'invalid json' } }],
}),
} as Response;
}
return {
ok: true,
json: async () => ({
choices: [{ message: { content: '{"value": "success", "count": 3}' } }],
}),
} as Response;
});
// Suppress console output during test
vi.spyOn(console, 'log').mockImplementation(() => { });
vi.spyOn(console, 'warn').mockImplementation(() => { });
vi.spyOn(console, 'error').mockImplementation(() => { });
const result = await callOpenRouter<{ value: string; count: number }>({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
jsonSchema: testSchema,
maxRetries: 2,
retryDelayMs: 10, // Fast retries for tests
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.value).toBe('success');
}
expect(callCount).toBe(3);
});
});
describe('parseJsonContent', () => {
it('should parse clean JSON', () => {
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
expect(result.foo).toBe('bar');
});
it('should handle markdown code fences', () => {
const result = parseJsonContent<{ foo: string }>('```json\n{"foo": "bar"}\n```');
expect(result.foo).toBe('bar');
});
it('should handle json without language specifier', () => {
const result = parseJsonContent<{ foo: string }>('```\n{"foo": "bar"}\n```');
expect(result.foo).toBe('bar');
});
it('should extract JSON from surrounding text', () => {
const result = parseJsonContent<{ foo: string }>('Here is the result: {"foo": "bar"} as requested.');
expect(result.foo).toBe('bar');
});
it('should throw on completely invalid content', () => {
vi.spyOn(console, 'error').mockImplementation(() => { });
expect(() => parseJsonContent('not json at all')).toThrow();
});
});

View File

@ -0,0 +1,167 @@
/**
* Shared OpenRouter API helper for structured JSON responses.
*/
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface JsonSchemaDefinition {
name: string;
schema: {
type: 'object';
properties: Record<string, unknown>;
required: string[];
additionalProperties: boolean;
};
}
export interface OpenRouterRequestOptions<T> {
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
model: string;
/** The prompt messages to send */
messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>;
/** JSON schema for structured output */
jsonSchema: JsonSchemaDefinition;
/** Number of retries on parsing failures (default: 0) */
maxRetries?: number;
/** Delay between retries in ms (default: 500) */
retryDelayMs?: number;
/** Job ID for logging purposes */
jobId?: string;
}
export interface OpenRouterResult<T> {
success: true;
data: T;
}
export interface OpenRouterError {
success: false;
error: string;
}
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
/**
* Call OpenRouter API with structured JSON output.
*
* @returns Parsed JSON response matching the schema, or an error object
*/
export async function callOpenRouter<T>(
options: OpenRouterRequestOptions<T>
): Promise<OpenRouterResponse<T>> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return { success: false, error: 'OPENROUTER_API_KEY not configured' };
}
const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log(`🔄 [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`);
await sleep(retryDelayMs * attempt);
}
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'JobOps',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages,
response_format: {
type: 'json_schema',
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
},
}),
});
if (!response.ok) {
// Throw error with status to allow specific retries
const errorBody = await response.text().catch(() => 'No error body');
const err = new Error(`OpenRouter API error: ${response.status}`);
(err as any).status = response.status;
(err as any).body = errorBody;
throw err;
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error('No content in response');
}
// Parse JSON - structured outputs should always return valid JSON
const parsed = parseJsonContent<T>(content, jobId);
return { success: true, data: parsed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as any).status;
// Retry on:
// 1. Parsing errors (AI returned malformed JSON)
// 2. Rate limits (429)
// 3. Server errors (5xx)
// 4. Timeouts/Network issues
const shouldRetry =
message.includes('parse') ||
status === 429 ||
(status >= 500 && status <= 599) ||
message.toLowerCase().includes('timeout') ||
message.toLowerCase().includes('fetch failed');
if (attempt < maxRetries && shouldRetry) {
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed (${status ?? 'no-status'}): ${message}. Retrying...`);
continue;
}
return { success: false, error: message };
}
}
return { success: false, error: 'All retry attempts failed' };
}
/**
* Parse JSON content from OpenRouter response.
* Handles common AI quirks like markdown code fences.
*/
export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = content.trim();
// Remove markdown code fences if present
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
// Try to extract JSON object if there's surrounding text
// Use non-greedy match and find the outermost braces
const firstBrace = candidate.indexOf('{');
const lastBrace = candidate.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
candidate = candidate.substring(firstBrace, lastBrace + 1);
}
try {
return JSON.parse(candidate) as T;
} catch (error) {
console.error(`❌ [${jobId ?? 'unknown'}] Failed to parse JSON:`, candidate.substring(0, 200));
throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'unknown'}`);
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generatePdf } from './pdf.js';
// Define mock data in hoisted block
const { mocks, mockProfile } = vi.hoisted(() => {
const profile = {
sections: {
summary: { content: 'Original Summary' },
skills: {
items: [
{ id: 's1', name: 'Existing Skill', visible: true, description: 'Existing Desc', level: 3, keywords: ['k1'] }
]
},
projects: { items: [] }
},
basics: { headline: 'Original Headline' }
};
return {
mockProfile: profile,
mocks: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
}
};
});
// Configure base mock implementations
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mocks.writeFile.mockResolvedValue(undefined);
vi.mock('fs/promises', async () => {
return {
default: mocks,
...mocks
};
});
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: vi.fn().mockReturnValue(true) }
}));
vi.mock('../repositories/settings.js', () => ({
getSetting: vi.fn().mockResolvedValue(null),
}));
vi.mock('./projectSelection.js', () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
}));
vi.mock('./resumeProjects.js', () => ({
extractProjectsFromProfile: vi.fn().mockReturnValue({ catalog: [], selectionItems: [] }),
resolveResumeProjectsSettings: vi.fn().mockReturnValue({
resumeProjects: { lockedProjectIds: [], aiSelectableProjectIds: [], maxProjects: 2 }
})
}));
vi.mock('child_process', () => ({
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
})),
default: {
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
}))
}
}));
describe('PDF Service Skills Validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
});
it('should add required schema fields (visible, description) to new skills', async () => {
// AI often returns just name and keywords
const newSkills = [
{ name: 'New Skill', keywords: ['k2'] },
{ name: 'Existing Skill', keywords: ['k3', 'k4'] } // Should merge with s1
];
const tailoredContent = { skills: newSkills };
await generatePdf('job-skills-1', tailoredContent, 'Job Desc');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
const skillItems = savedResumeJson.sections.skills.items;
// Check "New Skill"
const newSkill = skillItems.find((s: any) => s.name === 'New Skill');
expect(newSkill).toBeDefined();
// These are the validations failing in user report:
expect(newSkill.visible).toBe(true); // Should default to true
expect(typeof newSkill.description).toBe('string'); // Should default to ""
expect(newSkill.description).toBe('');
// Optional but good to check
expect(newSkill.id).toBeDefined();
expect(newSkill.level).toBe(1);
// Check "Existing Skill" - should preserve existing fields if not overwritten?
// In the implementation, we look up existing.
// existing.visible => true, existing.description => 'Existing Desc', existing.level => 3
const existingSkill = skillItems.find((s: any) => s.name === 'Existing Skill');
expect(existingSkill.visible).toBe(true);
expect(existingSkill.description).toBe('Existing Desc');
expect(existingSkill.level).toBe(3);
expect(existingSkill.keywords).toEqual(['k3', 'k4']); // Should use new keywords or existing? Implementation uses new || existing.
});
it('should sanitize base resume even if no skills are tailored', async () => {
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
// though our mock above has them. Let's make a truly invalid one locally)
const invalidProfile = {
...mockProfile,
sections: {
...mockProfile.sections,
skills: {
items: [
{ name: 'Invalid Skill' } // Missing visible, description, id, level
]
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile));
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
const item = savedResumeJson.sections.skills.items[0];
// Ensure defaults are applied even if we didn't use the tailoring logic block
expect(item.visible).toBe(true);
expect(item.description).toBe('');
expect(item.id).toBeDefined();
});
});

View File

@ -13,6 +13,7 @@ import { getSetting } from '../repositories/settings.js';
import { pickProjectIdsForJob } from './projectSelection.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
import { getDataDir } from '../config/dataDir.js';
import { getProfile } from './profile.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -49,18 +50,34 @@ export async function generatePdf(
selectedProjectIds?: string | null
): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId}...`);
const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json');
try {
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
await mkdir(OUTPUT_DIR, { recursive: true });
}
// Read base resume
const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8'));
const baseResume = baseResumePath
? JSON.parse(await readFile(baseResumePath, 'utf-8'))
: JSON.parse(JSON.stringify(await getProfile())); // Deep copy from cache
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({
...skill,
id: skill.id || `skill-${index}`,
visible: skill.visible ?? true,
// Zod schema requires string, default to empty string if missing
description: skill.description ?? '',
level: skill.level ?? 1,
keywords: skill.keywords || [],
}));
}
// Inject tailored summary
if (tailoredContent.summary) {
if (baseResume.sections?.summary) {
@ -81,14 +98,30 @@ export async function generatePdf(
// Inject tailored skills
if (tailoredContent.skills) {
const newSkills = Array.isArray(tailoredContent.skills)
? tailoredContent.skills
: typeof tailoredContent.skills === 'string'
? JSON.parse(tailoredContent.skills)
const newSkills = Array.isArray(tailoredContent.skills)
? tailoredContent.skills
: typeof tailoredContent.skills === 'string'
? JSON.parse(tailoredContent.skills)
: null;
if (newSkills && baseResume.sections?.skills) {
baseResume.sections.skills.items = newSkills;
// Ensure each skill item has required schema fields
const existingSkills = baseResume.sections.skills.items || [];
const skillsWithSchema = newSkills.map((newSkill: any, index: number) => {
// Try to find matching existing skill to preserve id and other fields
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
return {
id: newSkill.id || existing?.id || `skill-${index}`,
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
name: newSkill.name || existing?.name || '',
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
level: newSkill.level !== undefined ? newSkill.level : (existing?.level ?? 1),
keywords: newSkill.keywords || existing?.keywords || [],
};
});
baseResume.sections.skills.items = skillsWithSchema;
}
}
@ -131,11 +164,11 @@ export async function generatePdf(
} catch (err) {
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
}
// Write modified resume to temp file
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
// Generate PDF using Python script - output directly to our data folder
const outputFilename = `resume_${jobId}.pdf`;
const outputPath = join(OUTPUT_DIR, outputFilename);
@ -146,9 +179,9 @@ export async function generatePdf(
} catch {
// Ignore if it doesn't exist or cannot be removed.
}
await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR);
// Cleanup temp file
try {
const { unlink } = await import('fs/promises');
@ -156,7 +189,7 @@ export async function generatePdf(
} catch {
// Ignore cleanup errors
}
console.log(`✅ PDF generated: ${outputPath}`);
return { success: true, pdfPath: outputPath };
} catch (error) {
@ -177,7 +210,7 @@ async function runPythonPdfGenerator(
return new Promise((resolve, reject) => {
// Use the virtual environment's Python (or system python in Docker)
const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python');
const child = spawn(pythonPath, ['rxresume_automation.py'], {
cwd: RESUME_GEN_DIR,
env: {
@ -188,7 +221,7 @@ async function runPythonPdfGenerator(
},
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
@ -196,7 +229,7 @@ async function runPythonPdfGenerator(
reject(new Error(`Python script exited with code ${code}`));
}
});
child.on('error', reject);
});
}

View File

@ -0,0 +1,32 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFile } from 'fs/promises';
import { getProfile } from './profile.js';
vi.mock('fs/promises', async () => {
const fn = vi.fn();
return {
readFile: fn,
default: {
readFile: fn
}
};
});
describe('getProfile failure', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should throw an error if the profile file does not exist', async () => {
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory'));
await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory');
});
it('should throw an error if the profile file is invalid JSON', async () => {
vi.mocked(readFile).mockResolvedValue('invalid json');
await expect(getProfile('/invalid/json.json', true)).rejects.toThrow();
});
});

View File

@ -0,0 +1,48 @@
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
let cachedProfile: any = null;
let cachedProfilePath: string | null = null;
/**
* Get the base resume profile from base.json.
* Caches the result since it doesn't change often.
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
* @param forceRefresh Force reload from disk.
*/
export async function getProfile(profilePath?: string, forceRefresh = false): Promise<any> {
const targetPath = profilePath || DEFAULT_PROFILE_PATH;
if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) {
return cachedProfile;
}
try {
const content = await readFile(targetPath, 'utf-8');
cachedProfile = JSON.parse(content);
cachedProfilePath = targetPath;
return cachedProfile;
} catch (error) {
console.error(`❌ Failed to load profile from ${targetPath}:`, error);
throw error;
}
}
/**
* Get the person's name from the profile.
*/
export async function getPersonName(): Promise<string> {
const profile = await getProfile();
return profile?.basics?.name || 'Resume';
}
/**
* Clear the profile cache.
*/
export function clearProfileCache(): void {
cachedProfile = null;
}

View File

@ -1,8 +1,27 @@
/**
* Service for AI-powered project selection for resumes.
*/
import { getSetting } from '../repositories/settings.js';
import type { ResumeProjectSelectionItem } from './resumeProjects.js';
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
/** JSON schema for project selection response */
const PROJECT_SELECTION_SCHEMA: JsonSchemaDefinition = {
name: 'project_selection',
schema: {
type: 'object',
properties: {
selectedProjectIds: {
type: 'array',
items: { type: 'string' },
description: 'List of project IDs to include on the resume',
},
},
required: ['selectedProjectIds'],
additionalProperties: false,
},
};
export async function pickProjectIdsForJob(args: {
jobDescription: string;
@ -15,8 +34,7 @@ export async function pickProjectIdsForJob(args: {
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
if (eligibleIds.size === 0) return [];
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
if (!process.env.OPENROUTER_API_KEY) {
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
}
@ -31,53 +49,39 @@ export async function pickProjectIdsForJob(args: {
desiredCount,
});
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
});
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
model,
messages: [{ role: 'user', content: prompt }],
jsonSchema: PROJECT_SELECTION_SCHEMA,
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) throw new Error('No content in response');
const parsed = JSON.parse(content) as any;
const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : [];
const unique: string[] = [];
const seen = new Set<string>();
for (const id of selectedProjectIds) {
if (typeof id !== 'string') continue;
const trimmed = id.trim();
if (!trimmed) continue;
if (!eligibleIds.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
unique.push(trimmed);
if (unique.length >= desiredCount) break;
}
if (unique.length === 0) {
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
}
return unique;
} catch {
if (!result.success) {
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
}
const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds)
? result.data.selectedProjectIds
: [];
// Validate and dedupe the returned IDs
const unique: string[] = [];
const seen = new Set<string>();
for (const id of selectedProjectIds) {
if (typeof id !== 'string') continue;
const trimmed = id.trim();
if (!trimmed) continue;
if (!eligibleIds.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
unique.push(trimmed);
if (unique.length >= desiredCount) break;
}
if (unique.length === 0) {
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
}
return unique;
}
function buildProjectSelectionPrompt(args: {
@ -167,4 +171,3 @@ function truncate(input: string, maxChars: number): string {
if (input.length <= maxChars) return input;
return `${input.slice(0, maxChars - 1).trimEnd()}`;
}

View File

@ -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'
});

View File

@ -4,18 +4,12 @@ import { fileURLToPath } from 'url';
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
import { getProfile, DEFAULT_PROFILE_PATH } from './profile.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const DEFAULT_RESUME_PROFILE_PATH =
process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise<unknown> {
const content = await readFile(profilePath, 'utf-8');
return JSON.parse(content) as unknown;
}
export function extractProjectsFromProfile(profile: unknown): {
catalog: ResumeProjectCatalogItem[];
selectionItems: ResumeProjectSelectionItem[];
@ -58,7 +52,7 @@ export function buildDefaultResumeProjectsSettings(
.filter((id) => !lockedSet.has(id));
const total = catalog.length;
const preferredMax = Math.max(lockedProjectIds.length, 4);
const preferredMax = Math.max(lockedProjectIds.length, 3);
const maxProjects = total === 0 ? 0 : Math.min(total, preferredMax);
return normalizeResumeProjectsSettings(

View File

@ -0,0 +1,241 @@
/**
* Tests for scorer.ts - focusing on robust JSON parsing from AI responses
*/
import { describe, it, expect } from 'vitest';
import { parseJsonFromContent } from './scorer.js';
describe('parseJsonFromContent', () => {
describe('valid JSON inputs', () => {
it('should parse clean JSON object', () => {
const input = '{"score": 85, "reason": "Great match"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(85);
expect(result.reason).toBe('Great match');
});
it('should parse JSON with extra whitespace', () => {
const input = ' { "score" : 75 , "reason" : "Good fit" } ';
const result = parseJsonFromContent(input);
expect(result.score).toBe(75);
expect(result.reason).toBe('Good fit');
});
it('should parse JSON with newlines', () => {
const input = `{
"score": 90,
"reason": "Excellent match for the role"
}`;
const result = parseJsonFromContent(input);
expect(result.score).toBe(90);
expect(result.reason).toBe('Excellent match for the role');
});
});
describe('markdown code fences', () => {
it('should strip ```json code fences', () => {
const input = '```json\n{"score": 80, "reason": "Match"}\n```';
const result = parseJsonFromContent(input);
expect(result.score).toBe(80);
});
it('should strip ```JSON code fences (uppercase)', () => {
const input = '```JSON\n{"score": 80, "reason": "Match"}\n```';
const result = parseJsonFromContent(input);
expect(result.score).toBe(80);
});
it('should strip ``` code fences without language specifier', () => {
const input = '```\n{"score": 70, "reason": "Decent"}\n```';
const result = parseJsonFromContent(input);
expect(result.score).toBe(70);
});
it('should handle nested code fence patterns', () => {
const input = 'Here is the score:\n```json\n{"score": 65, "reason": "Partial match"}\n```\nEnd.';
const result = parseJsonFromContent(input);
expect(result.score).toBe(65);
});
});
describe('surrounding text', () => {
it('should extract JSON from text before', () => {
const input = 'Based on my analysis, here is my evaluation: {"score": 55, "reason": "Limited match"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(55);
});
it('should extract JSON from text after', () => {
const input = '{"score": 60, "reason": "Moderate match"} I hope this helps!';
const result = parseJsonFromContent(input);
expect(result.score).toBe(60);
});
it('should extract JSON from surrounding text on both sides', () => {
const input = 'Here is my response:\n\n{"score": 45, "reason": "Below average fit"}\n\nLet me know if you need more details.';
const result = parseJsonFromContent(input);
expect(result.score).toBe(45);
});
});
describe('common JSON formatting issues', () => {
it('should handle trailing comma before closing brace', () => {
const input = '{"score": 78, "reason": "Good skills",}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(78);
});
it('should handle single quotes instead of double quotes', () => {
const input = "{'score': 82, 'reason': 'Strong candidate'}";
const result = parseJsonFromContent(input);
expect(result.score).toBe(82);
});
it('should handle unquoted keys', () => {
const input = '{score: 77, reason: "Reasonable match"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(77);
});
it('should handle mixed issues (trailing comma, single quotes)', () => {
const input = "{'score': 68, 'reason': 'Average fit',}";
const result = parseJsonFromContent(input);
expect(result.score).toBe(68);
});
});
describe('decimal scores', () => {
it('should parse and round decimal scores', () => {
// parseJsonFromContent returns raw value for valid JSON; rounding only in regex fallback
const input = '{"score": 85.7, "reason": "Very good match"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(85.7);
});
it('should parse decimal scores in malformed text', () => {
const input = 'The score is score: 72.3, reason: "Above average"';
const result = parseJsonFromContent(input);
expect(result.score).toBe(72);
});
});
describe('malformed responses - regex fallback', () => {
it('should extract score from completely malformed response', () => {
const input = 'I think the score should be score: 50 and the reason: "Average candidate"';
const result = parseJsonFromContent(input);
expect(result.score).toBe(50);
});
it('should extract score with equals sign syntax', () => {
const input = 'score = 88, reason = "Excellent match"';
const result = parseJsonFromContent(input);
expect(result.score).toBe(88);
});
it('should handle reason with special characters', () => {
const input = '{"score": 73, "reason": "Good match! The candidate\'s skills align well."}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(73);
});
it('should provide default reason when only score is extractable', () => {
const input = 'I rate this candidate 85 out of 100 - score: 85';
const result = parseJsonFromContent(input);
expect(result.score).toBe(85);
expect(result.reason).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle zero score', () => {
const input = '{"score": 0, "reason": "No match at all"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(0);
});
it('should handle score of 100', () => {
const input = '{"score": 100, "reason": "Perfect candidate"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(100);
});
it('should handle empty reason', () => {
const input = '{"score": 50, "reason": ""}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(50);
expect(result.reason).toBe('');
});
it('should handle multiline reason', () => {
const input = `{"score": 70, "reason": "Good skills match. Experience is a bit lacking."}`;
const result = parseJsonFromContent(input);
expect(result.score).toBe(70);
expect(result.reason).toContain('Good skills match');
});
it('should handle unicode in reason', () => {
const input = '{"score": 80, "reason": "Great match ✓ for this role"}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(80);
});
});
describe('failure cases', () => {
it('should throw when no score can be extracted', () => {
const input = 'This is just plain text with no JSON or score.';
expect(() => parseJsonFromContent(input)).toThrow('Unable to parse JSON from model response');
});
it('should throw for empty input', () => {
expect(() => parseJsonFromContent('')).toThrow('Unable to parse JSON from model response');
});
it('should throw for only whitespace', () => {
expect(() => parseJsonFromContent(' \n\t ')).toThrow('Unable to parse JSON from model response');
});
});
describe('real-world AI responses', () => {
it('should handle GPT-style verbose response', () => {
const input = `Based on my analysis of the job description and candidate profile, I have evaluated the fit:
\`\`\`json
{
"score": 72,
"reason": "Strong React and TypeScript skills match. However, the role requires 5+ years experience which the candidate may not have."
}
\`\`\`
This score reflects the candidate's technical capabilities while accounting for the experience gap.`;
const result = parseJsonFromContent(input);
expect(result.score).toBe(72);
expect(result.reason).toContain('React and TypeScript');
});
it('should handle Claude-style response with thinking', () => {
const input = `Let me evaluate this candidate against the job requirements.
{"score": 83, "reason": "Excellent frontend skills with React and modern tooling. Good culture fit based on startup experience."}`;
const result = parseJsonFromContent(input);
expect(result.score).toBe(83);
});
it('should handle response with JSON5-style comments', () => {
// Some models output JSON5-like syntax with comments
const input = `{
"score": 67, // Good but not great
"reason": "Matches most requirements but lacks cloud experience"
}`;
// This will fail standard parse but regex should catch it
const result = parseJsonFromContent(input);
expect(result.score).toBe(67);
});
it('should handle response with extra properties', () => {
const input = '{"score": 79, "reason": "Good match", "confidence": "high", "breakdown": {"skills": 25, "experience": 20}}';
const result = parseJsonFromContent(input);
expect(result.score).toBe(79);
expect(result.reason).toBe('Good match');
});
});
});

View File

@ -4,23 +4,42 @@
import type { Job } from '../../shared/types.js';
import { getSetting } from '../repositories/settings.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
interface SuitabilityResult {
score: number; // 0-100
reason: string; // Explanation
}
/** JSON schema for suitability scoring response */
const SCORING_SCHEMA: JsonSchemaDefinition = {
name: 'job_suitability_score',
schema: {
type: 'object',
properties: {
score: {
type: 'integer',
description: 'Suitability score from 0 to 100',
},
reason: {
type: 'string',
description: 'Brief 1-2 sentence explanation of the score',
},
},
required: ['score', 'reason'],
additionalProperties: false,
},
};
/**
* Score a job's suitability based on profile and job description.
* Includes retry logic for when AI returns garbage responses.
*/
export async function scoreJobSuitability(
job: Job,
profile: Record<string, unknown>
): Promise<SuitabilityResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
if (!process.env.OPENROUTER_API_KEY) {
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
return mockScore(job);
}
@ -29,80 +48,130 @@ export async function scoreJobSuitability(
const overrideModelScorer = await getSetting('modelScorer');
// Precedence: Scorer-specific override > Global override > Env var > Default
const model = overrideModelScorer || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildScoringPrompt(job, profile);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content in response');
}
const parsed = parseJsonFromContent(content);
return {
score: Math.min(100, Math.max(0, parsed.score || 0)),
reason: parsed.reason || 'No explanation provided',
};
} catch (error) {
console.error('Failed to score job:', error);
const prompt = buildScoringPrompt(job, profile);
const result = await callOpenRouter<{ score: number; reason: string }>({
model,
messages: [{ role: 'user', content: prompt }],
jsonSchema: SCORING_SCHEMA,
maxRetries: 2,
jobId: job.id,
});
if (!result.success) {
console.error(`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`);
return mockScore(job);
}
const { score, reason } = result.data;
// Validate we got a reasonable response
if (typeof score !== 'number' || isNaN(score)) {
console.error(`❌ [Job ${job.id}] Invalid score in response, using mock scoring`);
return mockScore(job);
}
return {
score: Math.min(100, Math.max(0, Math.round(score))),
reason: reason || 'No explanation provided',
};
}
function parseJsonFromContent(content: string): { score?: number; reason?: string } {
const trimmed = content.trim();
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
const candidate = withoutFences;
/**
* Robustly parse JSON from AI-generated content.
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
*
* @deprecated Use callOpenRouter with structured outputs instead. Kept for backwards compatibility with tests.
*/
export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } {
const originalContent = content;
let candidate = content.trim();
// Step 1: Remove markdown code fences (with or without language specifier)
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
// Step 2: Try to extract JSON object if there's surrounding text
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
if (jsonMatch) {
candidate = jsonMatch[0];
}
// Step 3: Try direct parse first
try {
return JSON.parse(candidate);
} catch {
const firstBrace = candidate.indexOf('{');
const lastBrace = candidate.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
const sliced = candidate.slice(firstBrace, lastBrace + 1);
return JSON.parse(sliced);
}
throw new Error('Unable to parse JSON from model response');
// Continue with sanitization
}
// Step 4: Fix common JSON issues
let sanitized = candidate;
// Remove JavaScript-style comments (// and /* */)
sanitized = sanitized.replace(/\/\/[^\n]*/g, '');
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, '');
// Remove trailing commas before } or ]
sanitized = sanitized.replace(/,\s*([\]}])/g, '$1');
// Fix unquoted keys: word: -> "word":
// Be more careful - only match at start of object or after comma
sanitized = sanitized.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// Fix single quotes to double quotes
sanitized = sanitized.replace(/'/g, '"');
// Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON)
// First, let's normalize the string - escape actual newlines inside strings
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, (match) => {
if (match === '\n') return '\\n';
if (match === '\r') return '\\r';
if (match === '\t') return '\\t';
return '';
});
// Step 5: Try parsing the sanitized version
try {
return JSON.parse(sanitized);
} catch {
// Continue with more aggressive extraction
}
// Step 6: Even more aggressive - try to rebuild a minimal valid JSON
// by extracting just the score and reason values
const scoreMatch = originalContent.match(/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i);
const reasonMatch = originalContent.match(/["']?reason["']?\s*[:=]\s*["']([^"'\n]+)["']/i) ||
originalContent.match(/["']?reason["']?\s*[:=]\s*["']?(.*?)["']?\s*[,}\n]/is);
if (scoreMatch) {
const score = Math.round(parseFloat(scoreMatch[1]));
const reason = reasonMatch ? reasonMatch[1].trim().replace(/[\x00-\x1F\x7F]/g, '') : 'Score extracted from malformed response';
console.log(`⚠️ [Job ${jobId || 'unknown'}] Parsed score via regex fallback: ${score}`);
return { score, reason };
}
// Log the failure with full content for debugging
console.error(`❌ [Job ${jobId || 'unknown'}] Failed to parse AI response. Raw content (first 500 chars):`,
originalContent.substring(0, 500));
console.error(` Sanitized content (first 500 chars):`, sanitized.substring(0, 500));
throw new Error('Unable to parse JSON from model response');
}
function buildScoringPrompt(job: Job, profile: Record<string, unknown>): string {
return `
You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
Consider:
- Skills match (technologies, frameworks, languages)
- Experience level match
- Location/remote work alignment
- Industry/domain fit
- Career growth potential
SCORING CRITERIA:
- Skills match (technologies, frameworks, languages): 0-30 points
- Experience level match: 0-25 points
- Location/remote work alignment: 0-15 points
- Industry/domain fit: 0-15 points
- Career growth potential: 0-15 points
Candidate Profile:
CANDIDATE PROFILE:
${JSON.stringify(profile, null, 2)}
Job Listing:
JOB LISTING:
Title: ${job.title}
Employer: ${job.employer}
Location: ${job.location || 'Not specified'}
@ -110,33 +179,39 @@ Salary: ${job.salary || 'Not specified'}
Degree Required: ${job.degreeRequired || 'Not specified'}
Disciplines: ${job.disciplines || 'Not specified'}
Job Description:
JOB DESCRIPTION:
${job.jobDescription || 'No description available'}
Respond with JSON only (no code fences): { "score": <0-100>, "reason": "<brief explanation>" }
`;
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
REQUIRED FORMAT (exactly this structure):
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>"}
EXAMPLE VALID RESPONSE:
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
}
function mockScore(job: Job): SuitabilityResult {
// Simple keyword-based scoring as fallback
const jd = (job.jobDescription || '').toLowerCase();
const title = job.title.toLowerCase();
const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer'];
const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager'];
let score = 50;
for (const kw of goodKeywords) {
if (jd.includes(kw) || title.includes(kw)) score += 5;
}
for (const kw of badKeywords) {
if (jd.includes(kw) || title.includes(kw)) score -= 10;
}
score = Math.min(100, Math.max(0, score));
return {
score,
reason: 'Scored using keyword matching (API key not configured)',
@ -160,6 +235,6 @@ export async function scoreAndRankJobs(
};
})
);
return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
}

View File

@ -3,13 +3,12 @@
*/
import { getSetting } from '../repositories/settings.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
export interface TailoredData {
summary: string;
headline: string;
skills: any[];
skills: Array<{ name: string; keywords: string[] }>;
}
export interface TailoringResult {
@ -18,6 +17,46 @@ export interface TailoringResult {
error?: string;
}
/** JSON schema for resume tailoring response */
const TAILORING_SCHEMA: JsonSchemaDefinition = {
name: 'resume_tailoring',
schema: {
type: 'object',
properties: {
headline: {
type: 'string',
description: 'Job title headline matching the JD exactly',
},
summary: {
type: 'string',
description: 'Tailored resume summary paragraph',
},
skills: {
type: 'array',
description: 'Skills sections with keywords tailored to the job',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Skill category name (e.g., Frontend, Backend)',
},
keywords: {
type: 'array',
items: { type: 'string' },
description: 'List of skills/technologies in this category',
},
},
required: ['name', 'keywords'],
additionalProperties: false,
},
},
},
required: ['headline', 'summary', 'skills'],
additionalProperties: false,
},
};
/**
* Generate tailored resume content (summary, headline, skills) for a job.
*/
@ -25,65 +64,42 @@ export async function generateTailoring(
jobDescription: string,
profile: Record<string, unknown>
): Promise<TailoringResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
if (!process.env.OPENROUTER_API_KEY) {
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring');
return { success: false, error: 'API key not configured' };
}
const overrideModel = await getSetting('model');
const overrideModelTailoring = await getSetting('modelTailoring');
// Precedence: Tailoring-specific override > Global override > Env var > Default
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildTailoringPrompt(profile, jobDescription);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content in response');
}
const parsed = JSON.parse(content);
// Basic validation
if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) {
console.warn('⚠️ AI response missing required fields:', parsed);
}
return {
success: true,
data: {
summary: sanitizeText(parsed.summary || ''),
headline: sanitizeText(parsed.headline || ''),
skills: parsed.skills || []
}
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
const result = await callOpenRouter<TailoredData>({
model,
messages: [{ role: 'user', content: prompt }],
jsonSchema: TAILORING_SCHEMA,
});
if (!result.success) {
return { success: false, error: result.error };
}
const { summary, headline, skills } = result.data;
// Basic validation
if (!summary || !headline || !Array.isArray(skills)) {
console.warn('⚠️ AI response missing required fields:', result.data);
}
return {
success: true,
data: {
summary: sanitizeText(summary || ''),
headline: sanitizeText(headline || ''),
skills: skills || []
}
};
}
/**
@ -112,14 +128,14 @@ function buildTailoringPrompt(profile: Record<string, unknown>, jd: string): str
},
skills: (profile as any).sections?.skills || (profile as any).skills,
projects: (profile as any).sections?.projects?.items?.map((p: any) => ({
name: p.name,
description: p.description,
keywords: p.keywords
name: p.name,
description: p.description,
keywords: p.keywords
})),
experience: (profile as any).sections?.experience?.items?.map((e: any) => ({
company: e.company,
position: e.position,
summary: e.summary
company: e.company,
position: e.position,
summary: e.summary
}))
};
@ -127,8 +143,8 @@ function buildTailoringPrompt(profile: Record<string, unknown>, jd: string): str
You are an expert resume writer tailoring a profile for a specific job application.
You must return a JSON object with three fields: "headline", "summary", and "skills".
JOB DESCRIPTION:
${jd.slice(0, 3000)} ... (truncated if too long)
JOB DESCRIPTION (JD):
${jd}
MY PROFILE:
${JSON.stringify(relevantProfile, null, 2)}

View File

@ -0,0 +1,895 @@
// combined types from: https://github.com/amruthpillai/reactive-resume/tree/v4.5.5/libs/schema/src
import { z } from "zod";
// --- Shared ---
export type FilterKeys<T, Condition> = {
[Key in keyof T]: T[Key] extends Condition ? Key : never;
}[keyof T];
export const idSchema = z
.string()
.describe("Unique identifier for the item");
export const itemSchema = z.object({
id: idSchema,
visible: z.boolean(),
});
export type Item = z.infer<typeof itemSchema>;
export const defaultItem: Item = {
id: "",
visible: true,
};
export const urlSchema = z.object({
label: z.string(),
href: z.literal("").or(z.string().url()),
});
export type URL = z.infer<typeof urlSchema>;
export const defaultUrl: URL = {
label: "",
href: "",
};
// --- Basics ---
export const customFieldSchema = z.object({
id: z.string().cuid2(),
icon: z.string(),
name: z.string(),
value: z.string(),
});
export type CustomField = z.infer<typeof customFieldSchema>;
export const basicsSchema = z.object({
name: z.string(),
headline: z.string(),
email: z.literal("").or(z.string().email()),
phone: z.string(),
location: z.string(),
url: urlSchema,
customFields: z.array(customFieldSchema),
picture: z.object({
url: z.string(),
size: z.number().default(64),
aspectRatio: z.number().default(1),
borderRadius: z.number().default(0),
effects: z.object({
hidden: z.boolean().default(false),
border: z.boolean().default(false),
grayscale: z.boolean().default(false),
}),
}),
});
export type Basics = z.infer<typeof basicsSchema>;
export const defaultBasics: Basics = {
name: "",
headline: "",
email: "",
phone: "",
location: "",
url: defaultUrl,
customFields: [],
picture: {
url: "",
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: {
hidden: false,
border: false,
grayscale: false,
},
},
};
// --- Metadata ---
export const defaultLayout = [
[
["profiles", "summary", "experience", "education", "projects", "volunteer", "references"],
["skills", "interests", "certifications", "awards", "publications", "languages"],
],
];
export const metadataSchema = z.object({
template: z.string().default("rhyhorn"),
layout: z.array(z.array(z.array(z.string()))).default(defaultLayout), // pages -> columns -> sections
css: z.object({
value: z.string().default("* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"),
visible: z.boolean().default(false),
}),
page: z.object({
margin: z.number().default(18),
format: z.enum(["a4", "letter"]).default("a4"),
options: z.object({
breakLine: z.boolean().default(true),
pageNumbers: z.boolean().default(true),
}),
}),
theme: z.object({
background: z.string().default("#ffffff"),
text: z.string().default("#000000"),
primary: z.string().default("#dc2626"),
}),
typography: z.object({
font: z.object({
family: z.string().default("IBM Plex Serif"),
subset: z.string().default("latin"),
variants: z.array(z.string()).default(["regular"]),
size: z.number().default(14),
}),
lineHeight: z.number().default(1.5),
hideIcons: z.boolean().default(false),
underlineLinks: z.boolean().default(true),
}),
notes: z.string().default(""),
});
export type Metadata = z.infer<typeof metadataSchema>;
export const defaultMetadata: Metadata = {
template: "rhyhorn",
layout: defaultLayout,
css: {
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
visible: false,
},
page: {
margin: 18,
format: "a4",
options: {
breakLine: true,
pageNumbers: true,
},
},
theme: {
background: "#ffffff",
text: "#000000",
primary: "#dc2626",
},
typography: {
font: {
family: "IBM Plex Serif",
subset: "latin",
variants: ["regular", "italic", "600"],
size: 14,
},
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: "",
};
// --- Sections ---
// Award
export const awardSchema = itemSchema.extend({
title: z.string().min(1),
awarder: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Award = z.infer<typeof awardSchema>;
export const defaultAward: Award = {
...defaultItem,
title: "",
awarder: "",
date: "",
summary: "",
url: defaultUrl,
};
// Certification
export const certificationSchema = itemSchema.extend({
name: z.string().min(1),
issuer: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Certification = z.infer<typeof certificationSchema>;
export const defaultCertification: Certification = {
...defaultItem,
name: "",
issuer: "",
date: "",
summary: "",
url: defaultUrl,
};
// Custom Section
export const customSectionSchema = itemSchema.extend({
name: z.string(),
description: z.string(),
date: z.string(),
location: z.string(),
summary: z.string(),
keywords: z.array(z.string()).default([]),
url: urlSchema,
});
export type CustomSection = z.infer<typeof customSectionSchema>;
export const defaultCustomSection: CustomSection = {
...defaultItem,
name: "",
description: "",
date: "",
location: "",
summary: "",
keywords: [],
url: defaultUrl,
};
// Education
export const educationSchema = itemSchema.extend({
institution: z.string().min(1),
studyType: z.string(),
area: z.string(),
score: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Education = z.infer<typeof educationSchema>;
export const defaultEducation: Education = {
...defaultItem,
id: "",
institution: "",
studyType: "",
area: "",
score: "",
date: "",
summary: "",
url: defaultUrl,
};
// Experience
export const experienceSchema = itemSchema.extend({
company: z.string().min(1),
position: z.string(),
location: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Experience = z.infer<typeof experienceSchema>;
export const defaultExperience: Experience = {
...defaultItem,
company: "",
position: "",
location: "",
date: "",
summary: "",
url: defaultUrl,
};
// Interest
export const interestSchema = itemSchema.extend({
name: z.string().min(1),
keywords: z.array(z.string()).default([]),
});
export type Interest = z.infer<typeof interestSchema>;
export const defaultInterest: Interest = {
...defaultItem,
name: "",
keywords: [],
};
// Language
export const languageSchema = itemSchema.extend({
name: z.string().min(1),
description: z.string(),
level: z.coerce.number().min(0).max(5).default(1),
});
export type Language = z.infer<typeof languageSchema>;
export const defaultLanguage: Language = {
...defaultItem,
name: "",
description: "",
level: 1,
};
// Profile
export const profileSchema = itemSchema.extend({
network: z.string().min(1),
username: z.string().min(1),
icon: z
.string()
.describe(
'Slug for the icon from https://simpleicons.org. For example, "github", "linkedin", etc.',
),
url: urlSchema,
});
export type Profile = z.infer<typeof profileSchema>;
export const defaultProfile: Profile = {
...defaultItem,
network: "",
username: "",
icon: "",
url: defaultUrl,
};
// Project
export const projectSchema = itemSchema.extend({
name: z.string().min(1),
description: z.string(),
date: z.string(),
summary: z.string(),
keywords: z.array(z.string()).default([]),
url: urlSchema,
});
export type Project = z.infer<typeof projectSchema>;
export const defaultProject: Project = {
...defaultItem,
name: "",
description: "",
date: "",
summary: "",
keywords: [],
url: defaultUrl,
};
// Publication
export const publicationSchema = itemSchema.extend({
name: z.string().min(1),
publisher: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Publication = z.infer<typeof publicationSchema>;
export const defaultPublication: Publication = {
...defaultItem,
name: "",
publisher: "",
date: "",
summary: "",
url: defaultUrl,
};
// Reference
export const referenceSchema = itemSchema.extend({
name: z.string().min(1),
description: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Reference = z.infer<typeof referenceSchema>;
export const defaultReference: Reference = {
...defaultItem,
name: "",
description: "",
summary: "",
url: defaultUrl,
};
// Skill
export const skillSchema = itemSchema.extend({
name: z.string(),
description: z.string(),
level: z.coerce.number().min(0).max(5).default(1),
keywords: z.array(z.string()).default([]),
});
export type Skill = z.infer<typeof skillSchema>;
export const defaultSkill: Skill = {
...defaultItem,
name: "",
description: "",
level: 1,
keywords: [],
};
// Volunteer
export const volunteerSchema = itemSchema.extend({
organization: z.string().min(1),
position: z.string(),
location: z.string(),
date: z.string(),
summary: z.string(),
url: urlSchema,
});
export type Volunteer = z.infer<typeof volunteerSchema>;
export const defaultVolunteer: Volunteer = {
...defaultItem,
organization: "",
position: "",
location: "",
date: "",
summary: "",
url: defaultUrl,
};
// --- Aggregate Sections ---
export const sectionSchema = z.object({
name: z.string(),
columns: z.number().min(1).max(5).default(1),
separateLinks: z.boolean().default(true),
visible: z.boolean().default(true),
});
export const customSchema = sectionSchema.extend({
id: idSchema,
items: z.array(customSectionSchema),
});
export const sectionsSchema = z.object({
summary: sectionSchema.extend({
id: z.literal("summary"),
content: z.string().default(""),
}),
awards: sectionSchema.extend({
id: z.literal("awards"),
items: z.array(awardSchema),
}),
certifications: sectionSchema.extend({
id: z.literal("certifications"),
items: z.array(certificationSchema),
}),
education: sectionSchema.extend({
id: z.literal("education"),
items: z.array(educationSchema),
}),
experience: sectionSchema.extend({
id: z.literal("experience"),
items: z.array(experienceSchema),
}),
volunteer: sectionSchema.extend({
id: z.literal("volunteer"),
items: z.array(volunteerSchema),
}),
interests: sectionSchema.extend({
id: z.literal("interests"),
items: z.array(interestSchema),
}),
languages: sectionSchema.extend({
id: z.literal("languages"),
items: z.array(languageSchema),
}),
profiles: sectionSchema.extend({
id: z.literal("profiles"),
items: z.array(profileSchema),
}),
projects: sectionSchema.extend({
id: z.literal("projects"),
items: z.array(projectSchema),
}),
publications: sectionSchema.extend({
id: z.literal("publications"),
items: z.array(publicationSchema),
}),
references: sectionSchema.extend({
id: z.literal("references"),
items: z.array(referenceSchema),
}),
skills: sectionSchema.extend({
id: z.literal("skills"),
items: z.array(skillSchema),
}),
custom: z.record(z.string(), customSchema),
});
export type Section = z.infer<typeof sectionSchema>;
export type Sections = z.infer<typeof sectionsSchema>;
export type SectionKey = "basics" | keyof Sections | `custom.${string}`;
export type SectionWithItem<T = unknown> = Sections[FilterKeys<Sections, { items: T[] }>];
export type SectionItem = SectionWithItem["items"][number];
export type CustomSectionGroup = z.infer<typeof customSchema>;
export const defaultSection: Section = {
name: "",
columns: 1,
separateLinks: true,
visible: true,
};
export const defaultSections: Sections = {
summary: { ...defaultSection, id: "summary", name: "Summary", content: "" },
awards: { ...defaultSection, id: "awards", name: "Awards", items: [] },
certifications: { ...defaultSection, id: "certifications", name: "Certifications", items: [] },
education: { ...defaultSection, id: "education", name: "Education", items: [] },
experience: { ...defaultSection, id: "experience", name: "Experience", items: [] },
volunteer: { ...defaultSection, id: "volunteer", name: "Volunteering", items: [] },
interests: { ...defaultSection, id: "interests", name: "Interests", items: [] },
languages: { ...defaultSection, id: "languages", name: "Languages", items: [] },
profiles: { ...defaultSection, id: "profiles", name: "Profiles", items: [] },
projects: { ...defaultSection, id: "projects", name: "Projects", items: [] },
publications: { ...defaultSection, id: "publications", name: "Publications", items: [] },
references: { ...defaultSection, id: "references", name: "References", items: [] },
skills: { ...defaultSection, id: "skills", name: "Skills", items: [] },
custom: {},
};
// --- Main Resume Data ---
export const resumeDataSchema = z.object({
basics: basicsSchema,
sections: sectionsSchema,
metadata: metadataSchema,
});
export type ResumeData = z.infer<typeof resumeDataSchema>;
export const defaultResumeData: ResumeData = {
basics: defaultBasics,
sections: defaultSections,
metadata: defaultMetadata,
};
// --- Sample Data ---
export const sampleResume: ResumeData = {
basics: {
name: "John Doe",
headline: "Creative and Innovative Web Developer",
email: "john.doe@gmail.com",
phone: "(555) 123-4567",
location: "Pleasantville, CA 94588",
url: {
label: "",
href: "https://johndoe.me/",
},
customFields: [],
picture: {
url: "https://i.imgur.com/HgwyOuJ.jpg",
size: 120,
aspectRatio: 1,
borderRadius: 0,
effects: {
hidden: false,
border: false,
grayscale: false,
},
},
},
sections: {
summary: {
name: "Summary",
columns: 1,
separateLinks: true,
visible: true,
id: "summary",
content:
"<p>Innovative Web Developer with 5 years of experience in building impactful and user-friendly websites and applications. Specializes in <strong>front-end technologies</strong> and passionate about modern web standards and cutting-edge development techniques. Proven track record of leading successful projects from concept to deployment.</p>",
},
awards: {
name: "Awards",
columns: 1,
separateLinks: true,
visible: true,
id: "awards",
items: [],
},
certifications: {
name: "Certifications",
columns: 1,
separateLinks: true,
visible: true,
id: "certifications",
items: [
{
id: "spdhh9rrqi1gvj0yqnbqunlo",
visible: true,
name: "Full-Stack Web Development",
issuer: "CodeAcademy",
date: "2020",
summary: "",
url: {
label: "",
href: "",
},
},
{
id: "n838rddyqv47zexn6cxauwqp",
visible: true,
name: "AWS Certified Developer",
issuer: "Amazon Web Services",
date: "2019",
summary: "",
url: {
label: "",
href: "",
},
},
],
},
education: {
name: "Education",
columns: 1,
separateLinks: true,
visible: true,
id: "education",
items: [
{
id: "yo3p200zo45c6cdqc6a2vtt3",
visible: true,
institution: "University of California",
studyType: "Bachelor's in Computer Science",
area: "Berkeley, CA",
score: "",
date: "August 2012 to May 2016",
summary: "",
url: {
label: "",
href: "",
},
},
],
},
experience: {
name: "Experience",
columns: 1,
separateLinks: true,
visible: true,
id: "experience",
items: [
{
id: "lhw25d7gf32wgdfpsktf6e0x",
visible: true,
company: "Creative Solutions Inc.",
position: "Senior Web Developer",
location: "San Francisco, CA",
date: "January 2019 to Present",
summary:
"<ul><li><p>Spearheaded the redesign of the main product website, resulting in a 40% increase in user engagement.</p></li><li><p>Developed and implemented a new responsive framework, improving cross-device compatibility.</p></li><li><p>Mentored a team of four junior developers, fostering a culture of technical excellence.</p></li></ul>",
url: {
label: "",
href: "https://creativesolutions.inc/",
},
},
{
id: "r6543lil53ntrxmvel53gbtm",
visible: true,
company: "TechAdvancers",
position: "Web Developer",
location: "San Jose, CA",
date: "June 2016 to December 2018",
summary:
"<ul><li><p>Collaborated in a team of 10 to develop high-quality web applications using React.js and Node.js.</p></li><li><p>Managed the integration of third-party services such as Stripe for payments and Twilio for SMS services.</p></li><li><p>Optimized application performance, achieving a 30% reduction in load times.</p></li></ul>",
url: {
label: "",
href: "https://techadvancers.com/",
},
},
],
},
volunteer: {
name: "Volunteering",
columns: 1,
separateLinks: true,
visible: true,
id: "volunteer",
items: [],
},
interests: {
name: "Interests",
columns: 1,
separateLinks: true,
visible: true,
id: "interests",
items: [],
},
languages: {
name: "Languages",
columns: 1,
separateLinks: true,
visible: true,
id: "languages",
items: [],
},
profiles: {
name: "Profiles",
columns: 1,
separateLinks: true,
visible: true,
id: "profiles",
items: [
{
id: "cnbk5f0aeqvhx69ebk7hktwd",
visible: true,
network: "LinkedIn",
username: "johndoe",
icon: "linkedin",
url: {
label: "",
href: "https://linkedin.com/in/johndoe",
},
},
{
id: "ukl0uecvzkgm27mlye0wazlb",
visible: true,
network: "GitHub",
username: "johndoe",
icon: "github",
url: {
label: "",
href: "https://github.com/johndoe",
},
},
],
},
projects: {
name: "Projects",
columns: 1,
separateLinks: true,
visible: true,
id: "projects",
items: [
{
id: "yw843emozcth8s1ubi1ubvlf",
visible: true,
name: "E-Commerce Platform",
description: "Project Lead",
date: "",
summary:
"<p>Led the development of a full-stack e-commerce platform, improving sales conversion by 25%.</p>",
keywords: [],
url: {
label: "",
href: "",
},
},
{
id: "ncxgdjjky54gh59iz2t1xi1v",
visible: true,
name: "Interactive Dashboard",
description: "Frontend Developer",
date: "",
summary:
"<p>Created an interactive analytics dashboard for a SaaS application, enhancing data visualization for clients.</p>",
keywords: [],
url: {
label: "",
href: "",
},
},
],
},
publications: {
name: "Publications",
columns: 1,
separateLinks: true,
visible: true,
id: "publications",
items: [],
},
references: {
name: "References",
columns: 1,
separateLinks: true,
visible: false,
id: "references",
items: [
{
id: "f2sv5z0cce6ztjl87yuk8fak",
visible: true,
name: "Available upon request",
description: "",
summary: "",
url: {
label: "",
href: "",
},
},
],
},
skills: {
name: "Skills",
columns: 1,
separateLinks: true,
visible: true,
id: "skills",
items: [
{
id: "hn0keriukh6c0ojktl9gsgjm",
visible: true,
name: "Web Technologies",
description: "Advanced",
level: 0,
keywords: ["HTML5", "JavaScript", "PHP", "Python"],
},
{
id: "r8c3y47vykausqrgmzwg5pur",
visible: true,
name: "Web Frameworks",
description: "Intermediate",
level: 0,
keywords: ["React.js", "Angular", "Vue.js", "Laravel", "Django"],
},
{
id: "b5l75aseexqv17quvqgh73fe",
visible: true,
name: "Tools",
description: "Intermediate",
level: 0,
keywords: ["Webpack", "Git", "Jenkins", "Docker", "JIRA"],
},
],
},
custom: {},
},
metadata: {
template: "glalie",
layout: [
[
["summary", "experience", "education", "projects", "references"],
[
"profiles",
"skills",
"certifications",
"interests",
"languages",
"awards",
"volunteer",
"publications",
],
],
],
css: {
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
visible: false,
},
page: {
margin: 14,
format: "a4",
options: {
breakLine: true,
pageNumbers: true,
},
},
theme: {
background: "#ffffff",
text: "#000000",
primary: "#ca8a04",
},
typography: {
font: {
family: "Merriweather",
subset: "latin",
variants: ["regular"],
size: 13,
},
lineHeight: 1.75,
hideIcons: false,
underlineLinks: true,
},
notes: "",
},
};

View File

@ -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;