import { logger } from "@infra/logger"; import type { Job, JobSearchProfile } from "@shared/types"; import { LlmService } from "./llm/service"; import type { JsonSchemaDefinition } from "./llm/types"; import { resolveLlmModel } from "./modelSelection"; import { sanitizeProfileForPrompt } from "./scorer"; const COVER_LETTER_SCHEMA: JsonSchemaDefinition = { name: "cover_letter", schema: { type: "object", properties: { coverLetter: { type: "string", description: "Complete, ready-to-send cover letter (plain text, professional formatting with line breaks)", }, }, required: ["coverLetter"], additionalProperties: false, }, }; interface CoverLetterResponse { coverLetter: string; } function buildCoverLetterPrompt( job: Job, resumeProfile: Record, searchProfile: JobSearchProfile | null, ): string { const parts: string[] = []; parts.push( "Write a professional, compelling cover letter for this job application.", ); parts.push(""); parts.push("RULES:"); parts.push("- Keep it concise: 3-4 paragraphs, under 400 words"); parts.push( "- Lead with what makes the candidate a strong fit for THIS specific role", ); parts.push( "- Reference specific requirements from the job description and match them to candidate experience", ); parts.push( "- Be genuine and enthusiastic without being generic or sycophantic", ); parts.push("- Use a professional but personable tone"); parts.push("- Do NOT include placeholder text like [Your Name] or [Date]"); parts.push( "- Do NOT make up experience or skills the candidate doesn't have", ); parts.push( "- End with a strong closing that expresses interest in next steps", ); parts.push(""); parts.push("=== JOB DETAILS ==="); parts.push(`Title: ${job.title}`); parts.push(`Company: ${job.employer}`); if (job.location) parts.push(`Location: ${job.location}`); if (job.jobDescription) { const desc = job.jobDescription.length > 3000 ? `${job.jobDescription.slice(0, 3000)}...` : job.jobDescription; parts.push(`Description:\n${desc}`); } parts.push(""); parts.push("=== CANDIDATE RESUME ==="); parts.push(JSON.stringify(resumeProfile, null, 2)); if (searchProfile) { parts.push(""); parts.push("=== CANDIDATE PREFERENCES ==="); if (searchProfile.aboutMe) { parts.push(`About: ${searchProfile.aboutMe}`); } if (searchProfile.targetRoles.length > 0) { parts.push(`Target roles: ${searchProfile.targetRoles.join(", ")}`); } if (searchProfile.mustHaveSkills.length > 0) { parts.push( `Key skills to highlight: ${searchProfile.mustHaveSkills.join(", ")}`, ); } } return parts.join("\n"); } export async function generateCoverLetter( job: Job, resumeProfile: Record, searchProfile: JobSearchProfile | null, ): Promise<{ coverLetter: string }> { const model = await resolveLlmModel("scoring"); const prompt = buildCoverLetterPrompt( job, sanitizeProfileForPrompt(resumeProfile), searchProfile, ); const llm = new LlmService(); const result = await llm.callJson({ model, messages: [{ role: "user", content: prompt }], jsonSchema: COVER_LETTER_SCHEMA, maxRetries: 1, jobId: job.id, }); if (!result.success) { logger.error("Cover letter generation failed", { jobId: job.id, error: result.error, }); throw new Error(`Cover letter generation failed: ${result.error}`); } const letter = result.data.coverLetter; if (!letter || typeof letter !== "string") { throw new Error("LLM returned empty cover letter"); } return { coverLetter: letter }; }