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

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