robustness

This commit is contained in:
DaKheera47 2026-01-21 16:31:25 +00:00
parent cae54c39fb
commit 4b65f39650
8 changed files with 43 additions and 33 deletions

View File

@ -215,8 +215,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
setIsRegenerating(true);
await api.generateJobPdf(job.id);
toast.success("PDF regenerated");
setMode("ready");
await onJobUpdated();
setMode("ready");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message);
@ -282,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={`${personName.replace(/\s+/g, '_')}_${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>

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

@ -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) {
@ -22,7 +23,7 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
*/
profileRouter.get('/', async (req: Request, res: Response) => {
try {
const profile = await loadResumeProfile();
const profile = await getProfile();
res.json({ success: true, data: profile });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';

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

@ -87,7 +87,12 @@ export async function callOpenRouter<T>(
});
if (!response.ok) {
throw new Error(`OpenRouter API error: ${response.status}`);
// 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();
@ -104,10 +109,22 @@ export async function callOpenRouter<T>(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as any).status;
// Only retry on parsing errors
if (attempt < maxRetries && message.includes('parse')) {
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed: ${message}`);
// 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;
}
@ -129,9 +146,12 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
// Try to extract JSON object if there's surrounding text
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
if (jsonMatch) {
candidate = jsonMatch[0];
// 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 {

View File

@ -69,7 +69,7 @@ export async function generatePdf(
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-${Date.now()}-${index}`,
id: skill.id || `skill-${index}`,
visible: skill.visible ?? true,
// Zod schema requires string, default to empty string if missing
description: skill.description ?? '',
@ -112,7 +112,7 @@ export async function generatePdf(
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
return {
id: newSkill.id || existing?.id || `skill-${Date.now()}-${index}`,
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 || ''),

View File

@ -3,7 +3,7 @@ import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
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;

View File

@ -4,23 +4,12 @@ import { fileURLToPath } from 'url';
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
import { getProfile } from './profile.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> {
if (profilePath === DEFAULT_RESUME_PROFILE_PATH) {
return getProfile(profilePath);
}
const content = await readFile(profilePath, 'utf-8');
return JSON.parse(content) as unknown;
}
export function extractProjectsFromProfile(profile: unknown): {
catalog: ResumeProjectCatalogItem[];
selectionItems: ResumeProjectSelectionItem[];