174 lines
4.8 KiB
TypeScript
174 lines
4.8 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
/** 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;
|
|
eligibleProjects: ResumeProjectSelectionItem[];
|
|
desiredCount: number;
|
|
}): Promise<string[]> {
|
|
const desiredCount = Math.max(0, Math.floor(args.desiredCount));
|
|
if (desiredCount === 0) return [];
|
|
|
|
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
|
if (eligibleIds.size === 0) return [];
|
|
|
|
if (!process.env.OPENROUTER_API_KEY) {
|
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
|
}
|
|
|
|
const overrideModel = await getSetting('model');
|
|
const overrideModelProjectSelection = await getSetting('modelProjectSelection');
|
|
// Precedence: Project-specific override > Global override > Env var > Default
|
|
const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
|
|
|
const prompt = buildProjectSelectionPrompt({
|
|
jobDescription: args.jobDescription,
|
|
projects: args.eligibleProjects,
|
|
desiredCount,
|
|
});
|
|
|
|
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
|
|
model,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
jsonSchema: PROJECT_SELECTION_SCHEMA,
|
|
});
|
|
|
|
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: {
|
|
jobDescription: string;
|
|
projects: ResumeProjectSelectionItem[];
|
|
desiredCount: number;
|
|
}): string {
|
|
const projects = args.projects.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
description: p.description,
|
|
date: p.date,
|
|
summary: truncate(p.summaryText, 500),
|
|
}));
|
|
|
|
return `
|
|
You are selecting which projects to include on a resume for a specific job.
|
|
|
|
Rules:
|
|
- Choose up to ${args.desiredCount} project IDs.
|
|
- Only choose IDs from the provided list.
|
|
- Prefer projects that strongly match the job description keywords/tech stack.
|
|
- Prefer projects that signal impact and real-world engineering.
|
|
- Do NOT invent projects or skills.
|
|
|
|
Job description:
|
|
${args.jobDescription}
|
|
|
|
Candidate projects (pick from these IDs only):
|
|
${JSON.stringify(projects, null, 2)}
|
|
|
|
Respond with JSON only, in this exact shape:
|
|
{
|
|
"selectedProjectIds": ["id1", "id2"]
|
|
}
|
|
`.trim();
|
|
}
|
|
|
|
function fallbackPickProjectIds(
|
|
jobDescription: string,
|
|
eligibleProjects: ResumeProjectSelectionItem[],
|
|
desiredCount: number
|
|
): string[] {
|
|
const jd = (jobDescription || '').toLowerCase();
|
|
|
|
const signals = [
|
|
'react',
|
|
'typescript',
|
|
'javascript',
|
|
'node',
|
|
'next.js',
|
|
'nextjs',
|
|
'python',
|
|
'c++',
|
|
'c#',
|
|
'java',
|
|
'kotlin',
|
|
'sql',
|
|
'mongodb',
|
|
'aws',
|
|
'docker',
|
|
'graphql',
|
|
'php',
|
|
'unity',
|
|
'tailwind',
|
|
];
|
|
|
|
const activeSignals = signals.filter((s) => jd.includes(s));
|
|
|
|
const scored = eligibleProjects
|
|
.map((p) => {
|
|
const text = `${p.name} ${p.description} ${p.summaryText}`.toLowerCase();
|
|
let score = 0;
|
|
for (const signal of activeSignals) {
|
|
if (text.includes(signal)) score += 5;
|
|
}
|
|
if (/\b(open source|oss)\b/.test(text)) score += 2;
|
|
if (/\b(api|backend|frontend|full[- ]?stack)\b/.test(text)) score += 1;
|
|
return { id: p.id, score };
|
|
})
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
return scored.slice(0, desiredCount).map((s) => s.id);
|
|
}
|
|
|
|
function truncate(input: string, maxChars: number): string {
|
|
if (input.length <= maxChars) return input;
|
|
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
|
}
|