ilia fea00ae656 feat: search profiles, cover letters, discovery fixes
- 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
2026-04-05 19:35:14 -04:00

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