From 3d692f2f8b0e1750e3d65fd03c5ee512d798daa1 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 02:03:29 +0000 Subject: [PATCH 01/18] initial commit into hardening --- .../src/server/services/scorer.test.ts | 241 ++++++++++++++++ orchestrator/src/server/services/scorer.ts | 140 +++++++--- orchestrator/src/shared/rxresume-schema.ts | 258 ++++++++++++++++++ 3 files changed, 602 insertions(+), 37 deletions(-) create mode 100644 orchestrator/src/server/services/scorer.test.ts create mode 100644 orchestrator/src/shared/rxresume-schema.ts diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts new file mode 100644 index 0000000..08caa61 --- /dev/null +++ b/orchestrator/src/server/services/scorer.test.ts @@ -0,0 +1,241 @@ +/** + * Tests for scorer.ts - focusing on robust JSON parsing from AI responses + */ + +import { describe, it, expect } from 'vitest'; +import { parseJsonFromContent } from './scorer.js'; + +describe('parseJsonFromContent', () => { + describe('valid JSON inputs', () => { + it('should parse clean JSON object', () => { + const input = '{"score": 85, "reason": "Great match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBe('Great match'); + }); + + it('should parse JSON with extra whitespace', () => { + const input = ' { "score" : 75 , "reason" : "Good fit" } '; + const result = parseJsonFromContent(input); + expect(result.score).toBe(75); + expect(result.reason).toBe('Good fit'); + }); + + it('should parse JSON with newlines', () => { + const input = `{ + "score": 90, + "reason": "Excellent match for the role" + }`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(90); + expect(result.reason).toBe('Excellent match for the role'); + }); + }); + + describe('markdown code fences', () => { + it('should strip ```json code fences', () => { + const input = '```json\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ```JSON code fences (uppercase)', () => { + const input = '```JSON\n{"score": 80, "reason": "Match"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + + it('should strip ``` code fences without language specifier', () => { + const input = '```\n{"score": 70, "reason": "Decent"}\n```'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + }); + + it('should handle nested code fence patterns', () => { + const input = 'Here is the score:\n```json\n{"score": 65, "reason": "Partial match"}\n```\nEnd.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(65); + }); + }); + + describe('surrounding text', () => { + it('should extract JSON from text before', () => { + const input = 'Based on my analysis, here is my evaluation: {"score": 55, "reason": "Limited match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(55); + }); + + it('should extract JSON from text after', () => { + const input = '{"score": 60, "reason": "Moderate match"} I hope this helps!'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(60); + }); + + it('should extract JSON from surrounding text on both sides', () => { + const input = 'Here is my response:\n\n{"score": 45, "reason": "Below average fit"}\n\nLet me know if you need more details.'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(45); + }); + }); + + describe('common JSON formatting issues', () => { + it('should handle trailing comma before closing brace', () => { + const input = '{"score": 78, "reason": "Good skills",}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(78); + }); + + it('should handle single quotes instead of double quotes', () => { + const input = "{'score': 82, 'reason': 'Strong candidate'}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(82); + }); + + it('should handle unquoted keys', () => { + const input = '{score: 77, reason: "Reasonable match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(77); + }); + + it('should handle mixed issues (trailing comma, single quotes)', () => { + const input = "{'score': 68, 'reason': 'Average fit',}"; + const result = parseJsonFromContent(input); + expect(result.score).toBe(68); + }); + }); + + describe('decimal scores', () => { + it('should parse and round decimal scores', () => { + // parseJsonFromContent returns raw value for valid JSON; rounding only in regex fallback + const input = '{"score": 85.7, "reason": "Very good match"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85.7); + }); + + it('should parse decimal scores in malformed text', () => { + const input = 'The score is score: 72.3, reason: "Above average"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + }); + }); + + describe('malformed responses - regex fallback', () => { + it('should extract score from completely malformed response', () => { + const input = 'I think the score should be score: 50 and the reason: "Average candidate"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + }); + + it('should extract score with equals sign syntax', () => { + const input = 'score = 88, reason = "Excellent match"'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(88); + }); + + it('should handle reason with special characters', () => { + const input = '{"score": 73, "reason": "Good match! The candidate\'s skills align well."}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(73); + }); + + it('should provide default reason when only score is extractable', () => { + const input = 'I rate this candidate 85 out of 100 - score: 85'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(85); + expect(result.reason).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle zero score', () => { + const input = '{"score": 0, "reason": "No match at all"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(0); + }); + + it('should handle score of 100', () => { + const input = '{"score": 100, "reason": "Perfect candidate"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(100); + }); + + it('should handle empty reason', () => { + const input = '{"score": 50, "reason": ""}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(50); + expect(result.reason).toBe(''); + }); + + it('should handle multiline reason', () => { + const input = `{"score": 70, "reason": "Good skills match. Experience is a bit lacking."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(70); + expect(result.reason).toContain('Good skills match'); + }); + + it('should handle unicode in reason', () => { + const input = '{"score": 80, "reason": "Great match āœ“ for this role"}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(80); + }); + }); + + describe('failure cases', () => { + it('should throw when no score can be extracted', () => { + const input = 'This is just plain text with no JSON or score.'; + expect(() => parseJsonFromContent(input)).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for empty input', () => { + expect(() => parseJsonFromContent('')).toThrow('Unable to parse JSON from model response'); + }); + + it('should throw for only whitespace', () => { + expect(() => parseJsonFromContent(' \n\t ')).toThrow('Unable to parse JSON from model response'); + }); + }); + + describe('real-world AI responses', () => { + it('should handle GPT-style verbose response', () => { + const input = `Based on my analysis of the job description and candidate profile, I have evaluated the fit: + +\`\`\`json +{ + "score": 72, + "reason": "Strong React and TypeScript skills match. However, the role requires 5+ years experience which the candidate may not have." +} +\`\`\` + +This score reflects the candidate's technical capabilities while accounting for the experience gap.`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(72); + expect(result.reason).toContain('React and TypeScript'); + }); + + it('should handle Claude-style response with thinking', () => { + const input = `Let me evaluate this candidate against the job requirements. + +{"score": 83, "reason": "Excellent frontend skills with React and modern tooling. Good culture fit based on startup experience."}`; + const result = parseJsonFromContent(input); + expect(result.score).toBe(83); + }); + + it('should handle response with JSON5-style comments', () => { + // Some models output JSON5-like syntax with comments + const input = `{ + "score": 67, // Good but not great + "reason": "Matches most requirements but lacks cloud experience" +}`; + // This will fail standard parse but regex should catch it + const result = parseJsonFromContent(input); + expect(result.score).toBe(67); + }); + + it('should handle response with extra properties', () => { + const input = '{"score": 79, "reason": "Good match", "confidence": "high", "breakdown": {"skills": 25, "experience": 20}}'; + const result = parseJsonFromContent(input); + expect(result.score).toBe(79); + expect(result.reason).toBe('Good match'); + }); + }); +}); diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 4c7e18d..909a802 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -29,9 +29,9 @@ 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', @@ -47,19 +47,20 @@ export async function scoreJobSuitability( 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); + // Log raw response for debugging when issues occur + const parsed = parseJsonFromContent(content, job.id); return { score: Math.min(100, Math.max(0, parsed.score || 0)), reason: parsed.reason || 'No explanation provided', @@ -70,39 +71,98 @@ export async function scoreJobSuitability( } } -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. + */ +export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } { + const originalContent = content; + let candidate = content.trim(); + // Step 1: Remove markdown code fences (with or without language specifier) + candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim(); + + // Step 2: Try to extract JSON object if there's surrounding text + const jsonMatch = candidate.match(/\{[\s\S]*\}/); + if (jsonMatch) { + candidate = jsonMatch[0]; + } + + // Step 3: Try direct parse first try { return JSON.parse(candidate); } catch { - const firstBrace = candidate.indexOf('{'); - const lastBrace = candidate.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sliced = candidate.slice(firstBrace, lastBrace + 1); - return JSON.parse(sliced); - } - throw new Error('Unable to parse JSON from model response'); + // Continue with sanitization } + + // Step 4: Fix common JSON issues + let sanitized = candidate; + + // Remove JavaScript-style comments (// and /* */) + sanitized = sanitized.replace(/\/\/[^\n]*/g, ''); + sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Remove trailing commas before } or ] + sanitized = sanitized.replace(/,\s*([\]}])/g, '$1'); + + // Fix unquoted keys: word: -> "word": + // Be more careful - only match at start of object or after comma + sanitized = sanitized.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":'); + + // Fix single quotes to double quotes + sanitized = sanitized.replace(/'/g, '"'); + + // Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON) + // First, let's normalize the string - escape actual newlines inside strings + sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, (match) => { + if (match === '\n') return '\\n'; + if (match === '\r') return '\\r'; + if (match === '\t') return '\\t'; + return ''; + }); + + // Step 5: Try parsing the sanitized version + try { + return JSON.parse(sanitized); + } catch { + // Continue with more aggressive extraction + } + + // Step 6: Even more aggressive - try to rebuild a minimal valid JSON + // by extracting just the score and reason values + const scoreMatch = originalContent.match(/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i); + const reasonMatch = originalContent.match(/["']?reason["']?\s*[:=]\s*["']([^"'\n]+)["']/i) || + originalContent.match(/["']?reason["']?\s*[:=]\s*["']?(.*?)["']?\s*[,}\n]/is); + + if (scoreMatch) { + const score = Math.round(parseFloat(scoreMatch[1])); + const reason = reasonMatch ? reasonMatch[1].trim().replace(/[\x00-\x1F\x7F]/g, '') : 'Score extracted from malformed response'; + console.log(`āš ļø [Job ${jobId || 'unknown'}] Parsed score via regex fallback: ${score}`); + return { score, reason }; + } + + // Log the failure with full content for debugging + console.error(`āŒ [Job ${jobId || 'unknown'}] Failed to parse AI response. Raw content (first 500 chars):`, + originalContent.substring(0, 500)); + console.error(` Sanitized content (first 500 chars):`, sanitized.substring(0, 500)); + + throw new Error('Unable to parse JSON from model response'); } function buildScoringPrompt(job: Job, profile: Record): string { - return ` -You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100. + return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100. -Consider: -- Skills match (technologies, frameworks, languages) -- Experience level match -- Location/remote work alignment -- Industry/domain fit -- Career growth potential +SCORING CRITERIA: +- Skills match (technologies, frameworks, languages): 0-30 points +- Experience level match: 0-25 points +- Location/remote work alignment: 0-15 points +- Industry/domain fit: 0-15 points +- Career growth potential: 0-15 points -Candidate Profile: +CANDIDATE PROFILE: ${JSON.stringify(profile, null, 2)} -Job Listing: +JOB LISTING: Title: ${job.title} Employer: ${job.employer} Location: ${job.location || 'Not specified'} @@ -110,33 +170,39 @@ Salary: ${job.salary || 'Not specified'} Degree Required: ${job.degreeRequired || 'Not specified'} Disciplines: ${job.disciplines || 'Not specified'} -Job Description: +JOB DESCRIPTION: ${job.jobDescription || 'No description available'} -Respond with JSON only (no code fences): { "score": <0-100>, "reason": "" } -`; +IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON. + +REQUIRED FORMAT (exactly this structure): +{"score": , "reason": "<1-2 sentence explanation>"} + +EXAMPLE VALID RESPONSE: +{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`; } + function mockScore(job: Job): SuitabilityResult { // Simple keyword-based scoring as fallback const jd = (job.jobDescription || '').toLowerCase(); const title = job.title.toLowerCase(); - + const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer']; const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager']; - + let score = 50; - + for (const kw of goodKeywords) { if (jd.includes(kw) || title.includes(kw)) score += 5; } - + for (const kw of badKeywords) { if (jd.includes(kw) || title.includes(kw)) score -= 10; } - + score = Math.min(100, Math.max(0, score)); - + return { score, reason: 'Scored using keyword matching (API key not configured)', @@ -160,6 +226,6 @@ export async function scoreAndRankJobs( }; }) ); - + return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore); } diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts new file mode 100644 index 0000000..086daa8 --- /dev/null +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -0,0 +1,258 @@ +import { z } from "zod"; + +/** + * Schema matching the JSON you pasted (the "visible"/"summary"/"date"/"href" format). + * This is intentionally permissive (passthrough) so small future additions won't break parsing. + */ + +export const hrefUrlSchema = z.object({ + href: z.string().default(""), + label: z.string().default(""), +}); + +export const pictureEffectsSchema = z.object({ + border: z.boolean().default(false), + hidden: z.boolean().default(false), + grayscale: z.boolean().default(false), +}); + +export const basicsPictureSchema = z.object({ + url: z.string().default(""), + size: z.number().default(120), + effects: pictureEffectsSchema, + aspectRatio: z.number().default(1), + borderRadius: z.number().default(0), +}); + +export const customFieldSchema = z + .object({ + id: z.string().optional(), + icon: z.string().optional(), + text: z.string().optional(), + }) + .passthrough(); + +export const basicsSchema = z + .object({ + url: hrefUrlSchema, + name: z.string(), + email: z.string().email().or(z.literal("")).default(""), + phone: z.string().default(""), + picture: basicsPictureSchema, + headline: z.string().default(""), + location: z.string().default(""), + customFields: z.array(customFieldSchema).default([]), + }) + .passthrough(); + +export const metadataCssSchema = z.object({ + value: z.string().default(""), + visible: z.boolean().default(false), +}); + +export const metadataPageOptionsSchema = z.object({ + breakLine: z.boolean().default(false), + pageNumbers: z.boolean().default(false), +}); + +export const metadataPageSchema = z.object({ + format: z.enum(["a4", "letter"]).default("a4"), + margin: z.number().default(34), + options: metadataPageOptionsSchema.default({ breakLine: false, pageNumbers: false }), +}); + +export const metadataThemeSchema = z.object({ + text: z.string().default("#000000"), + primary: z.string().default("#475569"), + background: z.string().default("#ffffff"), +}); + +/** + * Your "layout" is shaped like: + * [ + * [ + * [ "summary", "profiles", ... ], // main column ids + * [ "skills", "languages" ] // sidebar column ids + * ], + * ... + * ] + */ +export const metadataLayoutSchema = z.array( + z.tuple([z.array(z.string()), z.array(z.string())]) +); + +export const metadataTypographySchema = z + .object({ + font: z.object({ + size: z.number().default(13), + family: z.string().default("IBM Plex Sans"), + subset: z.string().default("latin"), + variants: z.array(z.string()).default(["regular"]), + }), + hideIcons: z.boolean().default(false), + lineHeight: z.number().default(1.75), + underlineLinks: z.boolean().default(true), + }) + .passthrough(); + +export const metadataSchema = z + .object({ + css: metadataCssSchema, + page: metadataPageSchema, + notes: z.string().default(""), + theme: metadataThemeSchema, + layout: metadataLayoutSchema.default([]), + template: z.string().default("onyx"), + typography: metadataTypographySchema, + }) + .passthrough(); + +/** Common section container used by most sections in your JSON */ +export const baseSectionSchema = z + .object({ + id: z.string(), + name: z.string(), + columns: z.number().default(1), + visible: z.boolean().default(true), + separateLinks: z.boolean().default(true), + items: z.array(z.unknown()).default([]), + }) + .passthrough(); + +/** Item schemas (based on the items you included) */ +export const profileItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + icon: z.string().default(""), + network: z.string(), + visible: z.boolean().default(true), + username: z.string().default(""), + }) + .passthrough(); + +export const skillItemSchema = z + .object({ + id: z.string(), + name: z.string(), + level: z.number().default(0), + visible: z.boolean().default(true), + keywords: z.array(z.string()).default([]), + description: z.string().default(""), + }) + .passthrough(); + +export const projectItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + date: z.string().default(""), + name: z.string(), + summary: z.string().default(""), // HTML string in your data + visible: z.boolean().default(true), + keywords: z.array(z.string()).default([]), + description: z.string().default(""), + }) + .passthrough(); + +export const educationItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + area: z.string().default(""), + date: z.string().default(""), + score: z.string().default(""), + summary: z.string().default(""), // HTML string + visible: z.boolean().default(true), + studyType: z.string().default(""), + institution: z.string().default(""), + }) + .passthrough(); + +export const experienceItemSchema = z + .object({ + id: z.string(), + url: hrefUrlSchema, + date: z.string().default(""), + company: z.string(), + summary: z.string().default(""), // HTML string + visible: z.boolean().default(true), + location: z.string().default(""), + position: z.string().default(""), + }) + .passthrough(); + +/** Section schemas with typed items */ +export const profilesSectionSchema = baseSectionSchema.extend({ + items: z.array(profileItemSchema).default([]), +}); + +export const skillsSectionSchema = baseSectionSchema.extend({ + items: z.array(skillItemSchema).default([]), +}); + +export const projectsSectionSchema = baseSectionSchema.extend({ + items: z.array(projectItemSchema).default([]), +}); + +export const educationSectionSchema = baseSectionSchema.extend({ + items: z.array(educationItemSchema).default([]), +}); + +export const experienceSectionSchema = baseSectionSchema.extend({ + items: z.array(experienceItemSchema).default([]), +}); + +/** + * Your "summary" section is not an items array; it carries "content". + * Keep it separate. + */ +export const summarySectionSchema = z + .object({ + id: z.string(), + name: z.string(), + columns: z.number().default(1), + content: z.string().default(""), // HTML string + visible: z.boolean().default(true), + separateLinks: z.boolean().default(true), + }) + .passthrough(); + +/** Empty-ish sections (you have them as items: []) */ +export const emptyItemsSectionSchema = baseSectionSchema.extend({ + items: z.array(z.unknown()).default([]), +}); + +/** + * Your "sections" object contains a fixed set of keys, plus `custom: {}`. + * `custom` is an object with no guaranteed structure in your sample, so passthrough. + */ +export const sectionsSchema = z + .object({ + awards: emptyItemsSectionSchema, + custom: z.object({}).passthrough().default({}), + skills: skillsSectionSchema, + summary: summarySectionSchema, + profiles: profilesSectionSchema, + projects: projectsSectionSchema, + education: educationSectionSchema, + interests: emptyItemsSectionSchema, + languages: emptyItemsSectionSchema, + volunteer: emptyItemsSectionSchema, + experience: experienceSectionSchema, + references: emptyItemsSectionSchema, + publications: emptyItemsSectionSchema, + certifications: emptyItemsSectionSchema, + }) + .passthrough(); + +/** Top-level schema matching what you pasted */ +export const myResumeJsonSchema = z + .object({ + basics: basicsSchema, + metadata: metadataSchema, + sections: sectionsSchema, + }) + .passthrough(); + +export type MyResumeJson = z.infer; From c1bada9a4965882d4df6f22112c45605da6f106e Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 02:10:12 +0000 Subject: [PATCH 02/18] add retry --- orchestrator/src/server/services/scorer.ts | 98 ++++++++++++++-------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 909a802..e5714c7 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -14,6 +14,7 @@ interface SuitabilityResult { /** * 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, @@ -32,43 +33,72 @@ export async function scoreJobSuitability( 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' }, - }), - }); + const MAX_RETRIES = 2; + let lastError: Error | null = null; - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + console.log(`šŸ”„ [Job ${job.id}] Retry attempt ${attempt}/${MAX_RETRIES}...`); + // Small delay before retry + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + + 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'); + } + + // Try to parse the response + const parsed = parseJsonFromContent(content, job.id); + + // Validate we got a reasonable response + if (typeof parsed.score !== 'number' || isNaN(parsed.score)) { + throw new Error('Parsed response has no valid score'); + } + + return { + score: Math.min(100, Math.max(0, Math.round(parsed.score))), + reason: parsed.reason || 'No explanation provided', + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Only retry on parsing errors, not on network/API errors + if (lastError.message.includes('Unable to parse JSON') || + lastError.message.includes('Parsed response has no valid score')) { + console.warn(`āš ļø [Job ${job.id}] Attempt ${attempt + 1} failed: ${lastError.message}`); + continue; // Try again + } + + // For other errors, don't retry + break; } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - - if (!content) { - throw new Error('No content in response'); - } - - // Log raw response for debugging when issues occur - const parsed = parseJsonFromContent(content, job.id); - 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); - return mockScore(job); } + + console.error(`āŒ [Job ${job.id}] All ${MAX_RETRIES + 1} attempts failed, using mock scoring. Last error:`, lastError?.message); + return mockScore(job); } /** From edfad499a60705e602de4647c3476cc1dbaef666 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:11:16 +0000 Subject: [PATCH 03/18] better default model, better comment --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 7960390..c304924 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,16 @@ # OpenRouter API for AI scoring and summaries # Get your key at: https://openrouter.ai/keys OPENROUTER_API_KEY=your_openrouter_api_key_here -MODEL=openai/gpt-4o-mini +MODEL=google/gemini-3-flash-preview # RXResume credentials for PDF generation # Create an account at: https://v4.rxresu.me RXRESUME_EMAIL=your_email@example.com RXRESUME_PASSWORD=your_password_here -# Optional: Basic Auth for write access (read-only without auth) +# Optional: Basic Auth for write access +# the app is fully unauthenticated if this isn't set, which is the default # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. -# Browsing remains public and read-only. BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= From 5409faaf5fc556d48c3677e40223dafea026d460 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:26:15 +0000 Subject: [PATCH 04/18] use structured responses for openrouter calls --- orchestrator/src/server/services/manualJob.ts | 104 ++++----- .../src/server/services/openrouter.test.ts | 198 ++++++++++++++++++ .../src/server/services/openrouter.ts | 147 +++++++++++++ .../src/server/services/projectSelection.ts | 101 ++++----- orchestrator/src/server/services/scorer.ts | 113 ++++------ orchestrator/src/server/services/summary.ts | 136 ++++++------ 6 files changed, 572 insertions(+), 227 deletions(-) create mode 100644 orchestrator/src/server/services/openrouter.test.ts create mode 100644 orchestrator/src/server/services/openrouter.ts diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index f2cac94..d7e2e65 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -4,18 +4,57 @@ import { getSetting } from '../repositories/settings.js'; import type { ManualJobDraft } from '../../shared/types.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface ManualJobInferenceResult { job: ManualJobDraft; warning?: string | null; } -export async function inferManualJobDetails(jobDescription: string): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; +/** Raw response type from the API (all fields are strings) */ +interface ManualJobApiResponse { + title: string; + employer: string; + location: string; + salary: string; + deadline: string; + jobUrl: string; + applicationLink: string; + jobType: string; + jobLevel: string; + jobFunction: string; + disciplines: string; + degreeRequired: string; + starting: string; +} - if (!apiKey) { +/** JSON schema for manual job extraction response */ +const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = { + name: 'manual_job_details', + schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Job title' }, + employer: { type: 'string', description: 'Company/employer name' }, + location: { type: 'string', description: 'Job location' }, + salary: { type: 'string', description: 'Salary information' }, + deadline: { type: 'string', description: 'Application deadline' }, + jobUrl: { type: 'string', description: 'URL of the job listing' }, + applicationLink: { type: 'string', description: 'Direct application URL' }, + jobType: { type: 'string', description: 'Employment type (full-time, part-time, etc.)' }, + jobLevel: { type: 'string', description: 'Seniority level (entry, mid, senior, etc.)' }, + jobFunction: { type: 'string', description: 'Job function/category' }, + disciplines: { type: 'string', description: 'Required disciplines or fields' }, + degreeRequired: { type: 'string', description: 'Required degree or education' }, + starting: { type: 'string', description: 'Start date information' }, + }, + required: ['title', 'employer', 'location', 'salary', 'deadline', 'jobUrl', 'applicationLink', 'jobType', 'jobLevel', 'jobFunction', 'disciplines', 'degreeRequired', 'starting'], + additionalProperties: false, + }, +}; + +export async function inferManualJobDetails(jobDescription: string): Promise { + if (!process.env.OPENROUTER_API_KEY) { return { job: {}, warning: 'OPENROUTER_API_KEY not set. Fill details manually.', @@ -26,41 +65,21 @@ export async function inferManualJobDetails(jobDescription: string): Promise({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: MANUAL_JOB_SCHEMA, + }); - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - if (!content) { - throw new Error('No content in response'); - } - - const parsed = parseJsonFromContent(content); - return { job: normalizeDraft(parsed) }; - } catch (error) { - console.warn('Manual job inference failed:', error); + if (!result.success) { + console.warn('Manual job inference failed:', result.error); return { job: {}, warning: 'AI inference failed. Fill details manually.', }; } + + return { job: normalizeDraft(result.data) }; } function buildInferencePrompt(jd: string): string { @@ -106,23 +125,6 @@ OUTPUT FORMAT (JSON ONLY): `.trim(); } -function parseJsonFromContent(content: string): Record { - const trimmed = content.trim(); - const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim(); - - try { - return JSON.parse(withoutFences); - } catch { - const firstBrace = withoutFences.indexOf('{'); - const lastBrace = withoutFences.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sliced = withoutFences.slice(firstBrace, lastBrace + 1); - return JSON.parse(sliced); - } - throw new Error('Unable to parse JSON from model response'); - } -} - function normalizeDraft(parsed: Record): ManualJobDraft { const fields: Array = [ 'title', diff --git a/orchestrator/src/server/services/openrouter.test.ts b/orchestrator/src/server/services/openrouter.test.ts new file mode 100644 index 0000000..72ec8d4 --- /dev/null +++ b/orchestrator/src/server/services/openrouter.test.ts @@ -0,0 +1,198 @@ +/** + * 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, + } as Response); + + const result = await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('500'); + } + }); + + it('should handle empty response content', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '' } }], + }), + } as Response); + + const result = await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('No content'); + } + }); + + it('should include json_schema in request body', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"value": "test", "count": 1}' } }], + }), + } as Response); + + await callOpenRouter({ + model: 'test-model', + messages: [{ role: 'user', content: 'test prompt' }], + jsonSchema: testSchema, + }); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]?.body as string); + + expect(body.response_format.type).toBe('json_schema'); + expect(body.response_format.json_schema.name).toBe('test_schema'); + expect(body.response_format.json_schema.strict).toBe(true); + }); + + it('should retry on parsing failures when maxRetries is set', async () => { + let callCount = 0; + vi.mocked(global.fetch).mockImplementation(async () => { + callCount++; + if (callCount < 3) { + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: 'invalid json' } }], + }), + } as Response; + } + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"value": "success", "count": 3}' } }], + }), + } as Response; + }); + + // Suppress console output during test + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + + const result = await callOpenRouter<{ value: string; count: number }>({ + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + jsonSchema: testSchema, + maxRetries: 2, + retryDelayMs: 10, // Fast retries for tests + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('success'); + } + expect(callCount).toBe(3); + }); +}); + +describe('parseJsonContent', () => { + it('should parse clean JSON', () => { + const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}'); + expect(result.foo).toBe('bar'); + }); + + it('should handle markdown code fences', () => { + const result = parseJsonContent<{ foo: string }>('```json\n{"foo": "bar"}\n```'); + expect(result.foo).toBe('bar'); + }); + + it('should handle json without language specifier', () => { + const result = parseJsonContent<{ foo: string }>('```\n{"foo": "bar"}\n```'); + expect(result.foo).toBe('bar'); + }); + + it('should extract JSON from surrounding text', () => { + const result = parseJsonContent<{ foo: string }>('Here is the result: {"foo": "bar"} as requested.'); + expect(result.foo).toBe('bar'); + }); + + it('should throw on completely invalid content', () => { + vi.spyOn(console, 'error').mockImplementation(() => { }); + expect(() => parseJsonContent('not json at all')).toThrow(); + }); +}); diff --git a/orchestrator/src/server/services/openrouter.ts b/orchestrator/src/server/services/openrouter.ts new file mode 100644 index 0000000..328ccfc --- /dev/null +++ b/orchestrator/src/server/services/openrouter.ts @@ -0,0 +1,147 @@ +/** + * Shared OpenRouter API helper for structured JSON responses. + */ + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +export interface JsonSchemaDefinition { + name: string; + schema: { + type: 'object'; + properties: Record; + required: string[]; + additionalProperties: boolean; + }; +} + +export interface OpenRouterRequestOptions { + /** The model to use (e.g., 'google/gemini-3-flash-preview') */ + model: string; + /** The prompt messages to send */ + messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>; + /** JSON schema for structured output */ + jsonSchema: JsonSchemaDefinition; + /** Number of retries on parsing failures (default: 0) */ + maxRetries?: number; + /** Delay between retries in ms (default: 500) */ + retryDelayMs?: number; + /** Job ID for logging purposes */ + jobId?: string; +} + +export interface OpenRouterResult { + success: true; + data: T; +} + +export interface OpenRouterError { + success: false; + error: string; +} + +export type OpenRouterResponse = OpenRouterResult | OpenRouterError; + +/** + * Call OpenRouter API with structured JSON output. + * + * @returns Parsed JSON response matching the schema, or an error object + */ +export async function callOpenRouter( + options: OpenRouterRequestOptions +): Promise> { + const apiKey = process.env.OPENROUTER_API_KEY; + + if (!apiKey) { + return { success: false, error: 'OPENROUTER_API_KEY not configured' }; + } + + const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + console.log(`šŸ”„ [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`); + await sleep(retryDelayMs * attempt); + } + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'JobOps', + 'X-Title': 'JobOpsOrchestrator', + }, + body: JSON.stringify({ + model, + messages, + response_format: { + type: 'json_schema', + json_schema: { + name: jsonSchema.name, + strict: true, + schema: jsonSchema.schema, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`OpenRouter API error: ${response.status}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('No content in response'); + } + + // Parse JSON - structured outputs should always return valid JSON + const parsed = parseJsonContent(content, jobId); + + return { success: true, data: parsed }; + + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + // Only retry on parsing errors + if (attempt < maxRetries && message.includes('parse')) { + console.warn(`āš ļø [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed: ${message}`); + continue; + } + + return { success: false, error: message }; + } + } + + return { success: false, error: 'All retry attempts failed' }; +} + +/** + * Parse JSON content from OpenRouter response. + * Handles common AI quirks like markdown code fences. + */ +export function parseJsonContent(content: string, jobId?: string): T { + let candidate = content.trim(); + + // Remove markdown code fences if present + candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim(); + + // Try to extract JSON object if there's surrounding text + const jsonMatch = candidate.match(/\{[\s\S]*\}/); + if (jsonMatch) { + candidate = jsonMatch[0]; + } + + try { + return JSON.parse(candidate) as T; + } catch (error) { + console.error(`āŒ [${jobId ?? 'unknown'}] Failed to parse JSON:`, candidate.substring(0, 200)); + throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'unknown'}`); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index 1fd405f..a90530b 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -1,8 +1,27 @@ +/** + * Service for AI-powered project selection for resumes. + */ + import { getSetting } from '../repositories/settings.js'; - import type { ResumeProjectSelectionItem } from './resumeProjects.js'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +/** JSON schema for project selection response */ +const PROJECT_SELECTION_SCHEMA: JsonSchemaDefinition = { + name: 'project_selection', + schema: { + type: 'object', + properties: { + selectedProjectIds: { + type: 'array', + items: { type: 'string' }, + description: 'List of project IDs to include on the resume', + }, + }, + required: ['selectedProjectIds'], + additionalProperties: false, + }, +}; export async function pickProjectIdsForJob(args: { jobDescription: string; @@ -15,8 +34,7 @@ export async function pickProjectIdsForJob(args: { const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id)); if (eligibleIds.size === 0) return []; - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } @@ -31,53 +49,39 @@ export async function pickProjectIdsForJob(args: { desiredCount, }); - try { - const response = await fetch(OPENROUTER_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-Title': 'JobOpsOrchestrator', - }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - response_format: { type: 'json_object' }, - }), - }); + const result = await callOpenRouter<{ selectedProjectIds: string[] }>({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: PROJECT_SELECTION_SCHEMA, + }); - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - if (!content) throw new Error('No content in response'); - - const parsed = JSON.parse(content) as any; - const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : []; - const unique: string[] = []; - const seen = new Set(); - for (const id of selectedProjectIds) { - if (typeof id !== 'string') continue; - const trimmed = id.trim(); - if (!trimmed) continue; - if (!eligibleIds.has(trimmed)) continue; - if (seen.has(trimmed)) continue; - seen.add(trimmed); - unique.push(trimmed); - if (unique.length >= desiredCount) break; - } - - if (unique.length === 0) { - return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); - } - - return unique; - } catch { + if (!result.success) { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } + + const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds) + ? result.data.selectedProjectIds + : []; + + // Validate and dedupe the returned IDs + const unique: string[] = []; + const seen = new Set(); + for (const id of selectedProjectIds) { + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (!trimmed) continue; + if (!eligibleIds.has(trimmed)) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + unique.push(trimmed); + if (unique.length >= desiredCount) break; + } + + if (unique.length === 0) { + return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); + } + + return unique; } function buildProjectSelectionPrompt(args: { @@ -167,4 +171,3 @@ function truncate(input: string, maxChars: number): string { if (input.length <= maxChars) return input; return `${input.slice(0, maxChars - 1).trimEnd()}…`; } - diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index e5714c7..ea46c79 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -4,14 +4,33 @@ 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. @@ -20,8 +39,7 @@ export async function scoreJobSuitability( job: Job, profile: Record ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('āš ļø OPENROUTER_API_KEY not set, using mock scoring'); return mockScore(job); } @@ -33,77 +51,38 @@ export async function scoreJobSuitability( const prompt = buildScoringPrompt(job, profile); - const MAX_RETRIES = 2; - let lastError: Error | null = null; + const result = await callOpenRouter<{ score: number; reason: string }>({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: SCORING_SCHEMA, + maxRetries: 2, + jobId: job.id, + }); - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - if (attempt > 0) { - console.log(`šŸ”„ [Job ${job.id}] Retry attempt ${attempt}/${MAX_RETRIES}...`); - // Small delay before retry - await new Promise(resolve => setTimeout(resolve, 500 * attempt)); - } - - 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'); - } - - // Try to parse the response - const parsed = parseJsonFromContent(content, job.id); - - // Validate we got a reasonable response - if (typeof parsed.score !== 'number' || isNaN(parsed.score)) { - throw new Error('Parsed response has no valid score'); - } - - return { - score: Math.min(100, Math.max(0, Math.round(parsed.score))), - reason: parsed.reason || 'No explanation provided', - }; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - // Only retry on parsing errors, not on network/API errors - if (lastError.message.includes('Unable to parse JSON') || - lastError.message.includes('Parsed response has no valid score')) { - console.warn(`āš ļø [Job ${job.id}] Attempt ${attempt + 1} failed: ${lastError.message}`); - continue; // Try again - } - - // For other errors, don't retry - break; - } + if (!result.success) { + console.error(`āŒ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`); + return mockScore(job); } - console.error(`āŒ [Job ${job.id}] All ${MAX_RETRIES + 1} attempts failed, using mock scoring. Last error:`, lastError?.message); - 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', + }; } /** * 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; diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index fc70590..03fde39 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -3,13 +3,12 @@ */ import { getSetting } from '../repositories/settings.js'; - -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js'; export interface TailoredData { summary: string; headline: string; - skills: any[]; + skills: Array<{ name: string; keywords: string[] }>; } export interface TailoringResult { @@ -18,6 +17,46 @@ export interface TailoringResult { error?: string; } +/** JSON schema for resume tailoring response */ +const TAILORING_SCHEMA: JsonSchemaDefinition = { + name: 'resume_tailoring', + schema: { + type: 'object', + properties: { + headline: { + type: 'string', + description: 'Job title headline matching the JD exactly', + }, + summary: { + type: 'string', + description: 'Tailored resume summary paragraph', + }, + skills: { + type: 'array', + description: 'Skills sections with keywords tailored to the job', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Skill category name (e.g., Frontend, Backend)', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: 'List of skills/technologies in this category', + }, + }, + required: ['name', 'keywords'], + additionalProperties: false, + }, + }, + }, + required: ['headline', 'summary', 'skills'], + additionalProperties: false, + }, +}; + /** * Generate tailored resume content (summary, headline, skills) for a job. */ @@ -25,65 +64,42 @@ export async function generateTailoring( jobDescription: string, profile: Record ): Promise { - const apiKey = process.env.OPENROUTER_API_KEY; - - if (!apiKey) { + if (!process.env.OPENROUTER_API_KEY) { console.warn('āš ļø OPENROUTER_API_KEY not set, cannot generate tailoring'); return { success: false, error: 'API key not configured' }; } - + const overrideModel = await getSetting('model'); const overrideModelTailoring = await getSetting('modelTailoring'); // Precedence: Tailoring-specific override > Global override > Env var > Default const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildTailoringPrompt(profile, jobDescription); - - try { - const response = await fetch(OPENROUTER_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-Title': 'JobOpsOrchestrator', - }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - response_format: { type: 'json_object' }, - }), - }); - - if (!response.ok) { - throw new Error(`OpenRouter error: ${response.status}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - - if (!content) { - throw new Error('No content in response'); - } - - const parsed = JSON.parse(content); - - // Basic validation - if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) { - console.warn('āš ļø AI response missing required fields:', parsed); - } - return { - success: true, - data: { - summary: sanitizeText(parsed.summary || ''), - headline: sanitizeText(parsed.headline || ''), - skills: parsed.skills || [] - } - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { success: false, error: message }; + const result = await callOpenRouter({ + model, + messages: [{ role: 'user', content: prompt }], + jsonSchema: TAILORING_SCHEMA, + }); + + if (!result.success) { + return { success: false, error: result.error }; } + + const { summary, headline, skills } = result.data; + + // Basic validation + if (!summary || !headline || !Array.isArray(skills)) { + console.warn('āš ļø AI response missing required fields:', result.data); + } + + return { + success: true, + data: { + summary: sanitizeText(summary || ''), + headline: sanitizeText(headline || ''), + skills: skills || [] + } + }; } /** @@ -112,14 +128,14 @@ function buildTailoringPrompt(profile: Record, jd: string): str }, skills: (profile as any).sections?.skills || (profile as any).skills, projects: (profile as any).sections?.projects?.items?.map((p: any) => ({ - name: p.name, - description: p.description, - keywords: p.keywords + name: p.name, + description: p.description, + keywords: p.keywords })), experience: (profile as any).sections?.experience?.items?.map((e: any) => ({ - company: e.company, - position: e.position, - summary: e.summary + company: e.company, + position: e.position, + summary: e.summary })) }; @@ -127,8 +143,8 @@ function buildTailoringPrompt(profile: Record, jd: string): str You are an expert resume writer tailoring a profile for a specific job application. You must return a JSON object with three fields: "headline", "summary", and "skills". -JOB DESCRIPTION: -${jd.slice(0, 3000)} ... (truncated if too long) +JOB DESCRIPTION (JD): +${jd} MY PROFILE: ${JSON.stringify(relevantProfile, null, 2)} From ae5aa53b99a35811ccd29a86cca18b5a5b0af9ef Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:30:34 +0000 Subject: [PATCH 05/18] manual job type issue fix --- orchestrator/src/server/services/manualJob.ts | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index d7e2e65..7ceb079 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -125,41 +125,23 @@ OUTPUT FORMAT (JSON ONLY): `.trim(); } -function normalizeDraft(parsed: Record): ManualJobDraft { - const fields: Array = [ - 'title', - 'employer', - 'location', - 'salary', - 'deadline', - 'jobUrl', - 'applicationLink', - 'jobType', - 'jobLevel', - 'jobFunction', - 'disciplines', - 'degreeRequired', - 'starting', - ]; - +function normalizeDraft(parsed: ManualJobApiResponse): ManualJobDraft { const out: ManualJobDraft = {}; - for (const field of fields) { - const value = toCleanString(parsed[field]); - if (value) out[field] = value; - } + // Map each field, only including non-empty strings + if (parsed.title?.trim()) out.title = parsed.title.trim(); + if (parsed.employer?.trim()) out.employer = parsed.employer.trim(); + if (parsed.location?.trim()) out.location = parsed.location.trim(); + if (parsed.salary?.trim()) out.salary = parsed.salary.trim(); + if (parsed.deadline?.trim()) out.deadline = parsed.deadline.trim(); + if (parsed.jobUrl?.trim()) out.jobUrl = parsed.jobUrl.trim(); + if (parsed.applicationLink?.trim()) out.applicationLink = parsed.applicationLink.trim(); + if (parsed.jobType?.trim()) out.jobType = parsed.jobType.trim(); + if (parsed.jobLevel?.trim()) out.jobLevel = parsed.jobLevel.trim(); + if (parsed.jobFunction?.trim()) out.jobFunction = parsed.jobFunction.trim(); + if (parsed.disciplines?.trim()) out.disciplines = parsed.disciplines.trim(); + if (parsed.degreeRequired?.trim()) out.degreeRequired = parsed.degreeRequired.trim(); + if (parsed.starting?.trim()) out.starting = parsed.starting.trim(); return out; } - -function toCleanString(value: unknown): string | undefined { - if (value === null || value === undefined) return undefined; - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - return undefined; -} From 7cc5017e56ea2a13dcabfca84ace7a39d41958a0 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 12:36:11 +0000 Subject: [PATCH 06/18] max project limit set to below warning threshold --- orchestrator/src/server/services/resumeProjects.test.ts | 5 +++-- orchestrator/src/server/services/resumeProjects.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/orchestrator/src/server/services/resumeProjects.test.ts b/orchestrator/src/server/services/resumeProjects.test.ts index 8ca4d94..a43a891 100644 --- a/orchestrator/src/server/services/resumeProjects.test.ts +++ b/orchestrator/src/server/services/resumeProjects.test.ts @@ -66,7 +66,7 @@ describe('Resume Projects Logic', () => { }); it('should ensure maxProjects is at least len(locked)', () => { - const input = { + const input = { maxProjects: 1, // Too small lockedProjectIds: ['a', 'b'], aiSelectableProjectIds: [] @@ -105,6 +105,7 @@ describe('Resume Projects Logic', () => { // p1 is visible in base, so it should be locked by default expect(result.resumeProjects.lockedProjectIds).toEqual(['p1']); expect(result.resumeProjects.aiSelectableProjectIds).toEqual(['p2', 'p3']); + expect(result.resumeProjects.maxProjects).toBe(3); }); it('should apply valid overrides', () => { @@ -126,7 +127,7 @@ describe('Resume Projects Logic', () => { }); it('should handle invalid overrides by falling back to defaults', () => { - const result = rp.resolveResumeProjectsSettings({ + const result = rp.resolveResumeProjectsSettings({ catalog: mockCatalog, overrideRaw: '{"broken json' }); diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts index e97bad3..1d592c7 100644 --- a/orchestrator/src/server/services/resumeProjects.ts +++ b/orchestrator/src/server/services/resumeProjects.ts @@ -58,7 +58,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( From 164256326fbf9fec6e5ceba6be16a113dd6f5142 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 13:17:09 +0000 Subject: [PATCH 07/18] Ready panel can edit now --- .../src/client/components/ReadyPanel.tsx | 52 +++++++++++++++---- .../discovered-panel/TailorMode.tsx | 13 +++-- .../pages/orchestrator/JobDetailPanel.tsx | 11 ---- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index acc2ade..580cb63 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -3,6 +3,8 @@ * * Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied. * The PDF is the primary artifact, represented abstractly through an Application Kit summary. + * + * Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,14 +44,15 @@ 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 type { Job, ResumeProjectCatalogItem } from "../../shared/types"; +type PanelMode = "ready" | "tailor"; + interface ReadyPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; - onEditTailoring: () => void; - onEditDescription: () => void; } const safeFilenamePart = (value: string | null | undefined) => @@ -59,9 +62,8 @@ export const ReadyPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - onEditTailoring, - onEditDescription, }) => { + const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); const [catalog, setCatalog] = useState([]); @@ -77,6 +79,11 @@ export const ReadyPanel: React.FC = ({ 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 +205,23 @@ export const ReadyPanel: React.FC = ({ } }, [job]); + // Handler for regenerating PDF after tailoring edits + const handleTailorFinalize = useCallback(async () => { + if (!job) return; + try { + setIsRegenerating(true); + await api.generateJobPdf(job.id); + toast.success("PDF regenerated"); + setMode("ready"); + await onJobUpdated(); + } 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 +237,19 @@ export const ReadyPanel: React.FC = ({ ); } + // Tailor mode - reuse the same TailorMode component with 'ready' variant + if (mode === "tailor") { + return ( + setMode("ready")} + onFinalize={handleTailorFinalize} + isFinalizing={isRegenerating} + variant="ready" + /> + ); + } + return (
= ({ {/* Fix/Edit actions */} - + setMode("tailor")}> Edit tailoring @@ -345,11 +382,6 @@ export const ReadyPanel: React.FC = ({ {isRegenerating ? "Regenerating..." : "Regenerate PDF"} - - - Edit job description - - {/* Utility actions */} diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index 0af6b53..e25e07e 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -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 = ({ @@ -22,6 +24,7 @@ export const TailorMode: React.FC = ({ onBack, onFinalize, isFinalizing, + variant = 'discovered', }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); @@ -274,7 +277,7 @@ export const TailorMode: React.FC = ({
{!canFinalize && (

- 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'}.

)}

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

diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index e7a1297..44ea8cd 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -243,17 +243,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - onEditTailoring={() => { - onSetActiveTab("discovered"); - setTimeout(() => setDetailTab("tailoring"), 50); - }} - onEditDescription={() => { - onSetActiveTab("discovered"); - setTimeout(() => { - setDetailTab("description"); - setIsEditingDescription(true); - }, 50); - }} /> ); } From e5c99d54bf19a47149e091fdaac9fbe7f1a43755 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 14:26:57 +0000 Subject: [PATCH 08/18] formatting improvement --- orchestrator/src/server/services/pdf.ts | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 9af293e..d0a6434 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -49,18 +49,18 @@ export async function generatePdf( selectedProjectIds?: string | null ): Promise { console.log(`šŸ“„ Generating PDF for job ${jobId}...`); - + const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json'); - + try { // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { await mkdir(OUTPUT_DIR, { recursive: true }); } - + // Read base resume const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8')); - + // Inject tailored summary if (tailoredContent.summary) { if (baseResume.sections?.summary) { @@ -81,10 +81,10 @@ 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) { @@ -131,11 +131,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 +146,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 +156,7 @@ export async function generatePdf( } catch { // Ignore cleanup errors } - + console.log(`āœ… PDF generated: ${outputPath}`); return { success: true, pdfPath: outputPath }; } catch (error) { @@ -177,7 +177,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 +188,7 @@ async function runPythonPdfGenerator( }, stdio: 'inherit', }); - + child.on('close', (code) => { if (code === 0) { resolve(); @@ -196,7 +196,7 @@ async function runPythonPdfGenerator( reject(new Error(`Python script exited with code ${code}`)); } }); - + child.on('error', reject); }); } From 6a3a25578a0b4225b92c84b5eee23fa7aa39f176 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Wed, 21 Jan 2026 14:48:17 +0000 Subject: [PATCH 09/18] use person name from base.json for file downloads --- orchestrator/src/client/api/client.ts | 10 ++- .../src/client/components/ReadyPanel.tsx | 5 +- orchestrator/src/client/hooks/useProfile.ts | 90 +++++++++++++++++++ .../pages/orchestrator/JobDetailPanel.tsx | 5 +- orchestrator/src/server/api/routes/profile.ts | 17 +++- .../src/server/pipeline/orchestrator.ts | 17 +--- orchestrator/src/server/services/index.ts | 1 + orchestrator/src/server/services/pdf.ts | 5 +- orchestrator/src/server/services/profile.ts | 42 +++++++++ .../src/server/services/resumeProjects.ts | 5 ++ 10 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 orchestrator/src/client/hooks/useProfile.ts create mode 100644 orchestrator/src/server/services/profile.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 6558b78..1ed5c9f 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -171,7 +171,15 @@ export async function getSettings(): Promise { } export async function getProfileProjects(): Promise { - return fetchApi('/profile/projects'); + return fetchApi('/profile/projects', { + method: 'POST', + }); +} + +export async function getProfile(): Promise { + return fetchApi('/profile', { + method: 'POST', + }); } diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 580cb63..878b5fb 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -45,6 +45,7 @@ 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"; @@ -74,6 +75,8 @@ export const ReadyPanel: React.FC = ({ timeoutId: ReturnType; } | null>(null); + const { personName } = useProfile(); + // Load project catalog once useEffect(() => { api.getProfileProjects().then(setCatalog).catch(console.error); @@ -279,7 +282,7 @@ export const ReadyPanel: React.FC = ({