* clean up helpers * shared in it's own top level folder * workspaces setup * build fix * disable workspaces? * run ci * rename job-flow to gradcracker * optional dependencies * formatting? * more optional modules * allow post install runs * node bump * remove post install * add optionals * add more * formatting * comments, but im unsure * run typescript DIRECTLY * better build * camoufox simplification * lint * build process doesn't exist * build fix * lockfile * type check everything, build only for client * rename steps correctly * import from package! * fix formatting * don't fetch twice * fix concern
185 lines
4.7 KiB
TypeScript
185 lines
4.7 KiB
TypeScript
/**
|
|
* Service for AI-powered project selection for resumes.
|
|
*/
|
|
|
|
import { getSetting } from "../repositories/settings";
|
|
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
|
|
import type { ResumeProjectSelectionItem } from "./resumeProjects";
|
|
|
|
/** 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 [];
|
|
|
|
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
|
getSetting("model"),
|
|
getSetting("modelProjectSelection"),
|
|
]);
|
|
// Precedence: Project-specific override > Global override > Env var > Default
|
|
const model =
|
|
overrideModelProjectSelection ||
|
|
overrideModel ||
|
|
process.env.MODEL ||
|
|
"google/gemini-3-flash-preview";
|
|
|
|
const prompt = buildProjectSelectionPrompt({
|
|
jobDescription: args.jobDescription,
|
|
projects: args.eligibleProjects,
|
|
desiredCount,
|
|
});
|
|
|
|
const llm = new LlmService();
|
|
const result = await llm.callJson<{ 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",
|
|
"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()}…`;
|
|
}
|