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