use structured responses for openrouter calls

This commit is contained in:
DaKheera47 2026-01-21 12:26:15 +00:00
parent edfad499a6
commit 5409faaf5f
6 changed files with 572 additions and 227 deletions

View File

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

View File

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

View File

@ -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<string, unknown>;
required: string[];
additionalProperties: boolean;
};
}
export interface OpenRouterRequestOptions<T> {
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
model: string;
/** The prompt messages to send */
messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>;
/** JSON schema for structured output */
jsonSchema: JsonSchemaDefinition;
/** Number of retries on parsing failures (default: 0) */
maxRetries?: number;
/** Delay between retries in ms (default: 500) */
retryDelayMs?: number;
/** Job ID for logging purposes */
jobId?: string;
}
export interface OpenRouterResult<T> {
success: true;
data: T;
}
export interface OpenRouterError {
success: false;
error: string;
}
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
/**
* Call OpenRouter API with structured JSON output.
*
* @returns Parsed JSON response matching the schema, or an error object
*/
export async function callOpenRouter<T>(
options: OpenRouterRequestOptions<T>
): Promise<OpenRouterResponse<T>> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return { success: false, error: 'OPENROUTER_API_KEY not configured' };
}
const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log(`🔄 [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`);
await sleep(retryDelayMs * attempt);
}
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'JobOps',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages,
response_format: {
type: 'json_schema',
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
},
}),
});
if (!response.ok) {
throw 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<T>(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<T>(content: string, jobId?: string): T {
let candidate = content.trim();
// Remove markdown code fences if present
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
// Try to extract JSON object if there's surrounding text
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

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

View File

@ -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<string, unknown>
): Promise<SuitabilityResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
if (!process.env.OPENROUTER_API_KEY) {
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
return mockScore(job);
}
@ -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;

View File

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