* 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
198 lines
5.8 KiB
TypeScript
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();
|
|
}
|