* 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)
199 lines
5.8 KiB
TypeScript
199 lines
5.8 KiB
TypeScript
/**
|
|
* Service for generating tailored resume content (Summary, Headline, Skills).
|
|
*/
|
|
|
|
import { logger } from "@infra/logger";
|
|
import type { ResumeProfile } from "@shared/types";
|
|
import { getSetting } from "../repositories/settings";
|
|
import { LlmService } from "./llm/service";
|
|
import type { JsonSchemaDefinition } from "./llm/types";
|
|
|
|
export interface TailoredData {
|
|
summary: string;
|
|
headline: string;
|
|
skills: Array<{ name: string; keywords: string[] }>;
|
|
}
|
|
|
|
export interface TailoringResult {
|
|
success: boolean;
|
|
data?: TailoredData;
|
|
error?: string;
|
|
}
|
|
|
|
/** JSON schema for resume tailoring response */
|
|
const TAILORING_SCHEMA: JsonSchemaDefinition = {
|
|
name: "resume_tailoring",
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
headline: {
|
|
type: "string",
|
|
description: "Job title headline matching the JD exactly",
|
|
},
|
|
summary: {
|
|
type: "string",
|
|
description: "Tailored resume summary paragraph",
|
|
},
|
|
skills: {
|
|
type: "array",
|
|
description: "Skills sections with keywords tailored to the job",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
name: {
|
|
type: "string",
|
|
description: "Skill category name (e.g., Frontend, Backend)",
|
|
},
|
|
keywords: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "List of skills/technologies in this category",
|
|
},
|
|
},
|
|
required: ["name", "keywords"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ["headline", "summary", "skills"],
|
|
additionalProperties: false,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Generate tailored resume content (summary, headline, skills) for a job.
|
|
*/
|
|
export async function generateTailoring(
|
|
jobDescription: string,
|
|
profile: ResumeProfile,
|
|
): Promise<TailoringResult> {
|
|
const [overrideModel, overrideModelTailoring] = await Promise.all([
|
|
getSetting("model"),
|
|
getSetting("modelTailoring"),
|
|
]);
|
|
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
|
const model =
|
|
overrideModelTailoring ||
|
|
overrideModel ||
|
|
process.env.MODEL ||
|
|
"google/gemini-3-flash-preview";
|
|
const prompt = buildTailoringPrompt(profile, jobDescription);
|
|
|
|
const llm = new LlmService();
|
|
const result = await llm.callJson<TailoredData>({
|
|
model,
|
|
messages: [{ role: "user", content: prompt }],
|
|
jsonSchema: TAILORING_SCHEMA,
|
|
});
|
|
|
|
if (!result.success) {
|
|
const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`;
|
|
if (result.error.toLowerCase().includes("api key")) {
|
|
const message = `LLM API key not set, cannot generate tailoring. (${context})`;
|
|
logger.warn(message);
|
|
return { success: false, error: message };
|
|
}
|
|
return {
|
|
success: false,
|
|
error: `${result.error} (${context})`,
|
|
};
|
|
}
|
|
|
|
const { summary, headline, skills } = result.data;
|
|
|
|
// Basic validation
|
|
if (!summary || !headline || !Array.isArray(skills)) {
|
|
logger.warn("AI response missing required tailoring fields", result.data);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
summary: sanitizeText(summary || ""),
|
|
headline: sanitizeText(headline || ""),
|
|
skills: skills || [],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Backwards compatibility wrapper if needed, or alias.
|
|
*/
|
|
export async function generateSummary(
|
|
jobDescription: string,
|
|
profile: ResumeProfile,
|
|
): Promise<{ success: boolean; summary?: string; error?: string }> {
|
|
// If we just need summary, we can discard the rest (or cache it? but here we just return summary)
|
|
const result = await generateTailoring(jobDescription, profile);
|
|
return {
|
|
success: result.success,
|
|
summary: result.data?.summary,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
function buildTailoringPrompt(profile: ResumeProfile, jd: string): string {
|
|
// Extract only needed parts of profile to save tokens
|
|
const relevantProfile = {
|
|
basics: {
|
|
name: profile.basics?.name,
|
|
label: profile.basics?.label, // Original headline
|
|
summary: profile.basics?.summary,
|
|
},
|
|
skills: profile.sections?.skills,
|
|
projects: profile.sections?.projects?.items?.map((p) => ({
|
|
name: p.name,
|
|
description: p.description,
|
|
keywords: p.keywords,
|
|
})),
|
|
experience: profile.sections?.experience?.items?.map((e) => ({
|
|
company: e.company,
|
|
position: e.position,
|
|
summary: e.summary,
|
|
})),
|
|
};
|
|
|
|
return `
|
|
You are an expert resume writer tailoring a profile for a specific job application.
|
|
You must return a JSON object with three fields: "headline", "summary", and "skills".
|
|
|
|
JOB DESCRIPTION (JD):
|
|
${jd}
|
|
|
|
MY PROFILE:
|
|
${JSON.stringify(relevantProfile, null, 2)}
|
|
|
|
INSTRUCTIONS:
|
|
|
|
1. "headline" (String):
|
|
- CRITICAL: This is the #1 ATS factor.
|
|
- It must match the Job Title from the JD exactly (e.g., if JD says "Senior React Dev", use "Senior React Dev").
|
|
- If the JD title is very generic, you may add one specialty, but keep it matching the role.
|
|
|
|
2. "summary" (String):
|
|
- The Hook. This needs to mirror the company's "About You" / "What we're looking for" section.
|
|
- Keep it concise, warm, and confident.
|
|
- Do NOT invent experience.
|
|
- Use the profile to add context.
|
|
|
|
3. "skills" (Array of Objects):
|
|
- Review my existing skills section structure.
|
|
- Keyword Stuffing: Swap synonyms to match the JD exactly (e.g. "TDD" -> "Unit Testing", "ReactJS" -> "React").
|
|
- Keep my original skill levels and categories, just rename/reorder keywords to prioritize JD terms.
|
|
- Return the full "items" array for the skills section, preserving the structure: { "name": "Frontend", "keywords": [...] }.
|
|
|
|
OUTPUT FORMAT (JSON):
|
|
{
|
|
"headline": "...",
|
|
"summary": "...",
|
|
"skills": [ ... ]
|
|
}
|
|
`;
|
|
}
|
|
|
|
function sanitizeText(text: string): string {
|
|
return text
|
|
.replace(/\*\*[\s\S]*?\*\*/g, "") // remove markdown bold
|
|
.trim();
|
|
}
|