* 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)
186 lines
4.8 KiB
TypeScript
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()}…`;
|
|
}
|