robustness
This commit is contained in:
parent
cae54c39fb
commit
4b65f39650
@ -215,8 +215,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
setIsRegenerating(true);
|
setIsRegenerating(true);
|
||||||
await api.generateJobPdf(job.id);
|
await api.generateJobPdf(job.id);
|
||||||
toast.success("PDF regenerated");
|
toast.success("PDF regenerated");
|
||||||
setMode("ready");
|
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
|
setMode("ready");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||||
toast.error(message);
|
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">
|
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
||||||
<a
|
<a
|
||||||
href={pdfHref}
|
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" />
|
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">Download PDF</span>
|
<span className="truncate">Download PDF</span>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import * as jobsRepo from '../../repositories/jobs.js';
|
import * as jobsRepo from '../../repositories/jobs.js';
|
||||||
import { inferManualJobDetails } from '../../services/manualJob.js';
|
import { inferManualJobDetails } from '../../services/manualJob.js';
|
||||||
import { scoreJobSuitability } from '../../services/scorer.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';
|
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
export const manualJobsRouter = Router();
|
export const manualJobsRouter = Router();
|
||||||
@ -98,7 +98,7 @@ manualJobsRouter.post('/import', async (req: Request, res: Response) => {
|
|||||||
// Score asynchronously so the import returns immediately.
|
// Score asynchronously so the import returns immediately.
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const rawProfile = await loadResumeProfile();
|
const rawProfile = await getProfile();
|
||||||
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
throw new Error('Invalid resume profile format');
|
throw new Error('Invalid resume profile format');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
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();
|
export const profileRouter = Router();
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ export const profileRouter = Router();
|
|||||||
*/
|
*/
|
||||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const profile = await loadResumeProfile();
|
const profile = await getProfile();
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
res.json({ success: true, data: catalog });
|
res.json({ success: true, data: catalog });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -22,7 +23,7 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
profileRouter.get('/', async (req: Request, res: Response) => {
|
profileRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const profile = await loadResumeProfile();
|
const profile = await getProfile();
|
||||||
res.json({ success: true, data: profile });
|
res.json({ success: true, data: profile });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import { z } from 'zod';
|
|||||||
import * as settingsRepo from '../../repositories/settings.js';
|
import * as settingsRepo from '../../repositories/settings.js';
|
||||||
import {
|
import {
|
||||||
extractProjectsFromProfile,
|
extractProjectsFromProfile,
|
||||||
loadResumeProfile,
|
|
||||||
normalizeResumeProjectsSettings,
|
normalizeResumeProjectsSettings,
|
||||||
resolveResumeProjectsSettings,
|
resolveResumeProjectsSettings,
|
||||||
} from '../../services/resumeProjects.js';
|
} from '../../services/resumeProjects.js';
|
||||||
|
import { getProfile } from '../../services/profile.js';
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
|||||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
const profile = await loadResumeProfile();
|
const profile = await getProfile();
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
@ -216,7 +216,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
|||||||
if (resumeProjects === null) {
|
if (resumeProjects === null) {
|
||||||
await settingsRepo.setSetting('resumeProjects', null);
|
await settingsRepo.setSetting('resumeProjects', null);
|
||||||
} else {
|
} else {
|
||||||
const rawProfile = await loadResumeProfile();
|
const rawProfile = await getProfile();
|
||||||
|
|
||||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
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 defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
const profile = await loadResumeProfile();
|
const profile = await getProfile();
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|||||||
@ -87,7 +87,12 @@ export async function callOpenRouter<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const data = await response.json();
|
||||||
@ -104,10 +109,22 @@ export async function callOpenRouter<T>(
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = (error as any).status;
|
||||||
|
|
||||||
// Only retry on parsing errors
|
// Retry on:
|
||||||
if (attempt < maxRetries && message.includes('parse')) {
|
// 1. Parsing errors (AI returned malformed JSON)
|
||||||
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed: ${message}`);
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,9 +146,12 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
|
|||||||
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
|
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
|
||||||
|
|
||||||
// Try to extract JSON object if there's surrounding text
|
// Try to extract JSON object if there's surrounding text
|
||||||
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
// Use non-greedy match and find the outermost braces
|
||||||
if (jsonMatch) {
|
const firstBrace = candidate.indexOf('{');
|
||||||
candidate = jsonMatch[0];
|
const lastBrace = candidate.lastIndexOf('}');
|
||||||
|
|
||||||
|
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||||
|
candidate = candidate.substring(firstBrace, lastBrace + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export async function generatePdf(
|
|||||||
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
|
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
|
||||||
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({
|
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({
|
||||||
...skill,
|
...skill,
|
||||||
id: skill.id || `skill-${Date.now()}-${index}`,
|
id: skill.id || `skill-${index}`,
|
||||||
visible: skill.visible ?? true,
|
visible: skill.visible ?? true,
|
||||||
// Zod schema requires string, default to empty string if missing
|
// Zod schema requires string, default to empty string if missing
|
||||||
description: skill.description ?? '',
|
description: skill.description ?? '',
|
||||||
@ -112,7 +112,7 @@ export async function generatePdf(
|
|||||||
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
|
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
|
||||||
|
|
||||||
return {
|
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),
|
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
|
||||||
name: newSkill.name || existing?.name || '',
|
name: newSkill.name || existing?.name || '',
|
||||||
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
|
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { join, dirname } from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.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 cachedProfile: any = null;
|
||||||
let cachedProfilePath: string | null = null;
|
let cachedProfilePath: string | null = null;
|
||||||
|
|||||||
@ -4,23 +4,12 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
|
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));
|
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 };
|
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): {
|
export function extractProjectsFromProfile(profile: unknown): {
|
||||||
catalog: ResumeProjectCatalogItem[];
|
catalog: ResumeProjectCatalogItem[];
|
||||||
selectionItems: ResumeProjectSelectionItem[];
|
selectionItems: ResumeProjectSelectionItem[];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user