Shaheer Sarfaraz 16a8f1d15a
Use logger! add shim to convert backend responses to same format (#84)
* chore(orchestrator): add @infra import alias

* feat(server): add error/http/context/logger/sanitize infrastructure

* refactor(core): propagate request context, structured logs, and sanitization

* test/docs: update API contract assertions and contributor standards

* all pages working

* normalizing
2026-02-04 23:07:24 +00:00

198 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 { type JsonSchemaDefinition, LlmService } from "./llm-service";
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();
}