use structured responses for openrouter calls
This commit is contained in:
parent
edfad499a6
commit
5409faaf5f
@ -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',
|
||||
|
||||
198
orchestrator/src/server/services/openrouter.test.ts
Normal file
198
orchestrator/src/server/services/openrouter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
147
orchestrator/src/server/services/openrouter.ts
Normal file
147
orchestrator/src/server/services/openrouter.ts
Normal 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));
|
||||
}
|
||||
@ -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()}…`;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user