- Add search profiles (DB, API, settings UI) and wire into scorer/pipeline search terms. - Add cover letter generation (service, job action, JobDetail UI). - Align JobSpy Indeed country with country-level search geography when settings conflict; warn in logs. - Infer country from search cities via inferCountryKeyFromSearchGeography (shared). - Ignore extractor venv/storage and local data in Biome; ignore orchestrator/storage and JobSpy .venv in git. - Vite: do not watch orchestrator/storage (prevents reloads during startup.jobs pipeline). - JobSpy: document Python 3.10+ and venv setup in README/requirements. - Onboarding and settings: local resume path handling, orchestrator .env.example for Vite. Made-with: Cursor
132 lines
3.7 KiB
TypeScript
132 lines
3.7 KiB
TypeScript
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<string, unknown>,
|
|
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<string, unknown>,
|
|
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<CoverLetterResponse>({
|
|
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 };
|
|
}
|