Jobber/orchestrator/src/server/services/projectSelection.ts
Shaheer Sarfaraz b18c2eccbb
Code cleanup (#218)
* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep

* chore: configure knip with workspace entry points for all packages

* refactor(shared): split 1119-line types.ts into domain modules under types/

* refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly

* refactor(settings): migrate 4 manually-resolved settings into conversion registry

* refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator

* refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage

Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by
extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline
control logic into usePipelineControls.ts. Net negative line count across all files.

* feat: create settings registry (Step 1)

Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object.

* feat: derive schema, keys, and types from settings registry (Step 2)

Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry.

* refactor: gut envSettings and remove settings-conversion (Step 3)

Replaces manual env arrays with registry-driven maps in envSettings.ts.
Deletes settings-conversion.ts since all parsing/defaults now live in the registry.

* refactor: simplify getEffectiveSettings with generic loop (Step 4)

Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived.

* refactor: simplify settingsUpdateRegistry (Step 5)

Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects.

* refactor(settings): implement nested settings registry and clean up tests

- Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`)
- Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`)
- Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic
- Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline

* refactor(settings): address PR comments on env data and registry parsing

- Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries
- Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic)
- Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
2026-02-21 03:07:51 +00:00

186 lines
4.8 KiB
TypeScript

/**
* Service for AI-powered project selection for resumes.
*/
import { getSetting } from "../repositories/settings";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
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()}`;
}