use structured responses for openrouter calls
This commit is contained in:
@@ -4,18 +4,57 @@
|
|||||||
|
|
||||||
import { getSetting } from '../repositories/settings.js';
|
import { getSetting } from '../repositories/settings.js';
|
||||||
import type { ManualJobDraft } from '../../shared/types.js';
|
import type { ManualJobDraft } from '../../shared/types.js';
|
||||||
|
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
|
|
||||||
export interface ManualJobInferenceResult {
|
export interface ManualJobInferenceResult {
|
||||||
job: ManualJobDraft;
|
job: ManualJobDraft;
|
||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
|
/** Raw response type from the API (all fields are strings) */
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
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 {
|
return {
|
||||||
job: {},
|
job: {},
|
||||||
warning: 'OPENROUTER_API_KEY not set. Fill details manually.',
|
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 model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
const prompt = buildInferencePrompt(jobDescription);
|
const prompt = buildInferencePrompt(jobDescription);
|
||||||
|
|
||||||
try {
|
const result = await callOpenRouter<ManualJobApiResponse>({
|
||||||
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,
|
model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
response_format: { type: 'json_object' },
|
jsonSchema: MANUAL_JOB_SCHEMA,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!result.success) {
|
||||||
throw new Error(`OpenRouter error: ${response.status}`);
|
console.warn('Manual job inference failed:', result.error);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
return {
|
return {
|
||||||
job: {},
|
job: {},
|
||||||
warning: 'AI inference failed. Fill details manually.',
|
warning: 'AI inference failed. Fill details manually.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { job: normalizeDraft(result.data) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInferencePrompt(jd: string): string {
|
function buildInferencePrompt(jd: string): string {
|
||||||
@@ -106,23 +125,6 @@ OUTPUT FORMAT (JSON ONLY):
|
|||||||
`.trim();
|
`.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 {
|
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
|
||||||
const fields: Array<keyof ManualJobDraft> = [
|
const fields: Array<keyof ManualJobDraft> = [
|
||||||
'title',
|
'title',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Service for AI-powered project selection for resumes.
|
||||||
|
*/
|
||||||
|
|
||||||
import { getSetting } from '../repositories/settings.js';
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
|
||||||
import type { ResumeProjectSelectionItem } from './resumeProjects.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: {
|
export async function pickProjectIdsForJob(args: {
|
||||||
jobDescription: string;
|
jobDescription: string;
|
||||||
@@ -15,8 +34,7 @@ export async function pickProjectIdsForJob(args: {
|
|||||||
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||||
if (eligibleIds.size === 0) return [];
|
if (eligibleIds.size === 0) return [];
|
||||||
|
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
if (!apiKey) {
|
|
||||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,32 +49,21 @@ export async function pickProjectIdsForJob(args: {
|
|||||||
desiredCount,
|
desiredCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
|
||||||
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,
|
model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
response_format: { type: 'json_object' },
|
jsonSchema: PROJECT_SELECTION_SCHEMA,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!result.success) {
|
||||||
throw new Error(`OpenRouter error: ${response.status}`);
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds)
|
||||||
const content = data.choices[0]?.message?.content;
|
? result.data.selectedProjectIds
|
||||||
if (!content) throw new Error('No content in response');
|
: [];
|
||||||
|
|
||||||
const parsed = JSON.parse(content) as any;
|
// Validate and dedupe the returned IDs
|
||||||
const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : [];
|
|
||||||
const unique: string[] = [];
|
const unique: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const id of selectedProjectIds) {
|
for (const id of selectedProjectIds) {
|
||||||
@@ -75,9 +82,6 @@ export async function pickProjectIdsForJob(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return unique;
|
return unique;
|
||||||
} catch {
|
|
||||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProjectSelectionPrompt(args: {
|
function buildProjectSelectionPrompt(args: {
|
||||||
@@ -167,4 +171,3 @@ function truncate(input: string, maxChars: number): string {
|
|||||||
if (input.length <= maxChars) return input;
|
if (input.length <= maxChars) return input;
|
||||||
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,33 @@
|
|||||||
|
|
||||||
import type { Job } from '../../shared/types.js';
|
import type { Job } from '../../shared/types.js';
|
||||||
import { getSetting } from '../repositories/settings.js';
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
|
|
||||||
interface SuitabilityResult {
|
interface SuitabilityResult {
|
||||||
score: number; // 0-100
|
score: number; // 0-100
|
||||||
reason: string; // Explanation
|
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.
|
* Score a job's suitability based on profile and job description.
|
||||||
* Includes retry logic for when AI returns garbage responses.
|
* Includes retry logic for when AI returns garbage responses.
|
||||||
@@ -20,8 +39,7 @@ export async function scoreJobSuitability(
|
|||||||
job: Job,
|
job: Job,
|
||||||
profile: Record<string, unknown>
|
profile: Record<string, unknown>
|
||||||
): Promise<SuitabilityResult> {
|
): Promise<SuitabilityResult> {
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
if (!apiKey) {
|
|
||||||
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
|
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
|
||||||
return mockScore(job);
|
return mockScore(job);
|
||||||
}
|
}
|
||||||
@@ -33,77 +51,38 @@ export async function scoreJobSuitability(
|
|||||||
|
|
||||||
const prompt = buildScoringPrompt(job, profile);
|
const prompt = buildScoringPrompt(job, profile);
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const result = await callOpenRouter<{ score: number; reason: string }>({
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
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,
|
model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
response_format: { type: 'json_object' },
|
jsonSchema: SCORING_SCHEMA,
|
||||||
}),
|
maxRetries: 2,
|
||||||
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!result.success) {
|
||||||
throw new Error(`OpenRouter error: ${response.status}`);
|
console.error(`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`);
|
||||||
|
return mockScore(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const { score, reason } = result.data;
|
||||||
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
|
// Validate we got a reasonable response
|
||||||
if (typeof parsed.score !== 'number' || isNaN(parsed.score)) {
|
if (typeof score !== 'number' || isNaN(score)) {
|
||||||
throw new Error('Parsed response has no valid score');
|
console.error(`❌ [Job ${job.id}] Invalid score in response, using mock scoring`);
|
||||||
|
return mockScore(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score: Math.min(100, Math.max(0, Math.round(parsed.score))),
|
score: Math.min(100, Math.max(0, Math.round(score))),
|
||||||
reason: parsed.reason || 'No explanation provided',
|
reason: 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`❌ [Job ${job.id}] All ${MAX_RETRIES + 1} attempts failed, using mock scoring. Last error:`, lastError?.message);
|
|
||||||
return mockScore(job);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Robustly parse JSON from AI-generated content.
|
* Robustly parse JSON from AI-generated content.
|
||||||
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
* 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 } {
|
export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } {
|
||||||
const originalContent = content;
|
const originalContent = content;
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting } from '../repositories/settings.js';
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
|
|
||||||
export interface TailoredData {
|
export interface TailoredData {
|
||||||
summary: string;
|
summary: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
skills: any[];
|
skills: Array<{ name: string; keywords: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TailoringResult {
|
export interface TailoringResult {
|
||||||
@@ -18,6 +17,46 @@ export interface TailoringResult {
|
|||||||
error?: string;
|
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.
|
* Generate tailored resume content (summary, headline, skills) for a job.
|
||||||
*/
|
*/
|
||||||
@@ -25,9 +64,7 @@ export async function generateTailoring(
|
|||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
profile: Record<string, unknown>
|
profile: Record<string, unknown>
|
||||||
): Promise<TailoringResult> {
|
): Promise<TailoringResult> {
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring');
|
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring');
|
||||||
return { success: false, error: 'API key not configured' };
|
return { success: false, error: 'API key not configured' };
|
||||||
}
|
}
|
||||||
@@ -38,52 +75,31 @@ export async function generateTailoring(
|
|||||||
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
const prompt = buildTailoringPrompt(profile, jobDescription);
|
||||||
|
|
||||||
try {
|
const result = await callOpenRouter<TailoredData>({
|
||||||
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,
|
model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
response_format: { type: 'json_object' },
|
jsonSchema: TAILORING_SCHEMA,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!result.success) {
|
||||||
throw new Error(`OpenRouter error: ${response.status}`);
|
return { success: false, error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const { summary, headline, skills } = result.data;
|
||||||
const content = data.choices[0]?.message?.content;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw new Error('No content in response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) {
|
if (!summary || !headline || !Array.isArray(skills)) {
|
||||||
console.warn('⚠️ AI response missing required fields:', parsed);
|
console.warn('⚠️ AI response missing required fields:', result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
summary: sanitizeText(parsed.summary || ''),
|
summary: sanitizeText(summary || ''),
|
||||||
headline: sanitizeText(parsed.headline || ''),
|
headline: sanitizeText(headline || ''),
|
||||||
skills: parsed.skills || []
|
skills: skills || []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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 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".
|
You must return a JSON object with three fields: "headline", "summary", and "skills".
|
||||||
|
|
||||||
JOB DESCRIPTION:
|
JOB DESCRIPTION (JD):
|
||||||
${jd.slice(0, 3000)} ... (truncated if too long)
|
${jd}
|
||||||
|
|
||||||
MY PROFILE:
|
MY PROFILE:
|
||||||
${JSON.stringify(relevantProfile, null, 2)}
|
${JSON.stringify(relevantProfile, null, 2)}
|
||||||
|
|||||||
Reference in New Issue
Block a user