Jobber/orchestrator/src/server/services/projectSelection.ts
Shaheer Sarfaraz b94f85b149
Reduce low risk duplication (#79)
* 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
2026-02-02 21:30:14 +00:00

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