ai can update more fields
This commit is contained in:
@@ -133,6 +133,8 @@ const migrations = [
|
||||
`ALTER TABLE jobs ADD COLUMN vacancy_count INTEGER`,
|
||||
`ALTER TABLE jobs ADD COLUMN work_from_home_type TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
||||
|
||||
@@ -59,6 +59,8 @@ export const jobs = sqliteTable('jobs', {
|
||||
suitabilityScore: real('suitability_score'),
|
||||
suitabilityReason: text('suitability_reason'),
|
||||
tailoredSummary: text('tailored_summary'),
|
||||
tailoredHeadline: text('tailored_headline'),
|
||||
tailoredSkills: text('tailored_skills'),
|
||||
selectedProjectIds: text('selected_project_ids'),
|
||||
pdfPath: text('pdf_path'),
|
||||
notionPageId: text('notion_page_id'),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { runCrawler } from '../services/crawler.js';
|
||||
import { runJobSpy } from '../services/jobspy.js';
|
||||
import { runUkVisaJobs } from '../services/ukvisajobs.js';
|
||||
import { scoreJobSuitability } from '../services/scorer.js';
|
||||
import { generateSummary } from '../services/summary.js';
|
||||
import { generateTailoring } from '../services/summary.js';
|
||||
import { generatePdf } from '../services/pdf.js';
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from '../services/projectSelection.js';
|
||||
@@ -391,13 +391,18 @@ export async function summarizeJob(
|
||||
|
||||
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
|
||||
|
||||
// 1. Generate Summary
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
if (!tailoredSummary || options?.force) {
|
||||
console.log(' Generating summary...');
|
||||
const summaryResult = await generateSummary(job.jobDescription || '', profile);
|
||||
if (summaryResult.success) {
|
||||
tailoredSummary = summaryResult.summary ?? null;
|
||||
let tailoredHeadline = job.tailoredHeadline;
|
||||
let tailoredSkills = job.tailoredSkills;
|
||||
|
||||
if (!tailoredSummary || !tailoredHeadline || options?.force) {
|
||||
console.log(' Generating tailoring (summary, headline, skills)...');
|
||||
const tailoringResult = await generateTailoring(job.jobDescription || '', profile);
|
||||
if (tailoringResult.success && tailoringResult.data) {
|
||||
tailoredSummary = tailoringResult.data.summary;
|
||||
tailoredHeadline = tailoringResult.data.headline;
|
||||
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +434,8 @@ export async function summarizeJob(
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
tailoredSummary: tailoredSummary ?? undefined,
|
||||
tailoredHeadline: tailoredHeadline ?? undefined,
|
||||
tailoredSkills: tailoredSkills ?? undefined,
|
||||
selectedProjectIds: selectedProjectIds ?? undefined,
|
||||
});
|
||||
|
||||
@@ -459,7 +466,11 @@ export async function generateFinalPdf(
|
||||
|
||||
const pdfResult = await generatePdf(
|
||||
job.id,
|
||||
job.tailoredSummary || '',
|
||||
{
|
||||
summary: job.tailoredSummary || '',
|
||||
headline: job.tailoredHeadline || '',
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
|
||||
},
|
||||
job.jobDescription || '',
|
||||
DEFAULT_PROFILE_PATH,
|
||||
job.selectedProjectIds
|
||||
|
||||
@@ -27,18 +27,24 @@ export interface PdfResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TailoredPdfContent {
|
||||
summary?: string | null;
|
||||
headline?: string | null;
|
||||
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a tailored PDF resume for a job.
|
||||
*
|
||||
* @param jobId - Unique job identifier (used for filename)
|
||||
* @param tailoredSummary - The AI-generated summary to inject
|
||||
* @param jobDescription - Job description text for project selection
|
||||
* @param baseResumePath - Path to the base resume JSON (optional)
|
||||
* @param selectedProjectIds - Comma-separated list of selected project IDs (optional manual override)
|
||||
* @param jobId - Unique job identifier
|
||||
* @param tailoredContent - Content to inject (summary, headline, skills)
|
||||
* @param jobDescription - Job description (for project selection)
|
||||
* @param baseResumePath - Optional path to base JSON
|
||||
* @param selectedProjectIds - Optional overrides
|
||||
*/
|
||||
export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredSummary: string,
|
||||
tailoredContent: TailoredPdfContent,
|
||||
jobDescription: string,
|
||||
baseResumePath?: string,
|
||||
selectedProjectIds?: string | null
|
||||
@@ -57,10 +63,34 @@ export async function generatePdf(
|
||||
const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8'));
|
||||
|
||||
// Inject tailored summary
|
||||
if (baseResume.sections?.summary) {
|
||||
baseResume.sections.summary.content = tailoredSummary;
|
||||
} else if (baseResume.basics?.summary) {
|
||||
baseResume.basics.summary = tailoredSummary;
|
||||
if (tailoredContent.summary) {
|
||||
if (baseResume.sections?.summary) {
|
||||
baseResume.sections.summary.content = tailoredContent.summary;
|
||||
} else if (baseResume.basics?.summary) {
|
||||
baseResume.basics.summary = tailoredContent.summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored headline
|
||||
if (tailoredContent.headline) {
|
||||
if (baseResume.basics) {
|
||||
// Support both standard JSON Resume 'label' and RxResume 'headline'
|
||||
baseResume.basics.headline = tailoredContent.headline;
|
||||
baseResume.basics.label = tailoredContent.headline;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored skills
|
||||
if (tailoredContent.skills) {
|
||||
const newSkills = Array.isArray(tailoredContent.skills)
|
||||
? tailoredContent.skills
|
||||
: typeof tailoredContent.skills === 'string'
|
||||
? JSON.parse(tailoredContent.skills)
|
||||
: null;
|
||||
|
||||
if (newSkills && baseResume.sections?.skills) {
|
||||
baseResume.sections.skills.items = newSkills;
|
||||
}
|
||||
}
|
||||
|
||||
// Select projects (manual override OR locked + AI-picked) and set visibility for RXResume
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
/**
|
||||
* Service for generating tailored resume summaries.
|
||||
* Wraps the existing Python generate_summary.py script.
|
||||
* Service for generating tailored resume content (Summary, Headline, Skills).
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RESUME_GEN_DIR = join(__dirname, '../../../../resume-generator');
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface SummaryResult {
|
||||
export interface TailoredData {
|
||||
summary: string;
|
||||
headline: string;
|
||||
skills: any[];
|
||||
}
|
||||
|
||||
export interface TailoringResult {
|
||||
success: boolean;
|
||||
summary?: string;
|
||||
data?: TailoredData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a tailored resume summary for a job.
|
||||
* Uses the native implementation instead of calling Python.
|
||||
* Generate tailored resume content (summary, headline, skills) for a job.
|
||||
*/
|
||||
export async function generateSummary(
|
||||
export async function generateTailoring(
|
||||
jobDescription: string,
|
||||
profile: Record<string, unknown>
|
||||
): Promise<SummaryResult> {
|
||||
): Promise<TailoringResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate summary');
|
||||
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring');
|
||||
return { success: false, error: 'API key not configured' };
|
||||
}
|
||||
|
||||
const model = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
|
||||
const prompt = buildSummaryPrompt(profile, jobDescription);
|
||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
@@ -51,6 +45,7 @@ export async function generateSummary(
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -59,104 +54,110 @@ export async function generateSummary(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const summary = data.choices[0]?.message?.content;
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!summary) {
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
return { success: true, summary: sanitizeTailoredSummary(summary) };
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Basic validation
|
||||
if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) {
|
||||
console.warn('⚠️ AI response missing required fields:', parsed);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
summary: sanitizeText(parsed.summary || ''),
|
||||
headline: sanitizeText(parsed.headline || ''),
|
||||
skills: parsed.skills || []
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryPrompt(profile: Record<string, unknown>, jd: string): string {
|
||||
return `
|
||||
You are generating a tailored résumé summary for me.
|
||||
|
||||
Requirements:
|
||||
- Use keywords found in the job description.
|
||||
- Keep it concise but meaningful. Avoid fluff. Avoid long-winded text.
|
||||
- Include just enough detail to feel real and grounded.
|
||||
- Gently convey that I care about helping people and doing good work.
|
||||
- Do NOT invent experience or skills I don't have.
|
||||
- Maintain a warm, confident, human tone.
|
||||
- Target THIS specific job directly, so use ATS keywords, while remaining natural.
|
||||
- Use the profile to add context and details.
|
||||
|
||||
My profile (JSON fields merged):
|
||||
${JSON.stringify(profile, null, 2)}
|
||||
|
||||
Job description:
|
||||
${jd}
|
||||
|
||||
Write the résumé summary now.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative: Call the Python script directly.
|
||||
* Useful if the Python script has additional functionality.
|
||||
* Backwards compatibility wrapper if needed, or alias.
|
||||
*/
|
||||
export async function generateSummaryViaPython(
|
||||
jobDescription: string
|
||||
): Promise<SummaryResult> {
|
||||
const tempFile = join(RESUME_GEN_DIR, `temp_jd_${randomUUID()}.txt`);
|
||||
|
||||
try {
|
||||
// Write JD to temp file
|
||||
await writeFile(tempFile, jobDescription);
|
||||
|
||||
// Call Python script
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
const child = spawn('python3', ['generate_summary.py', '--file', tempFile], {
|
||||
cwd: RESUME_GEN_DIR,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(error || `Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
return { success: true, summary: sanitizeTailoredSummary(result) };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
// Cleanup temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
export async function generateSummary(
|
||||
jobDescription: string,
|
||||
profile: Record<string, unknown>
|
||||
): 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 sanitizeTailoredSummary(summary: string): string {
|
||||
const withoutBoldPreface = summary.replace(/\*\*[\s\S]*?\*\*/g, '');
|
||||
return withoutBoldPreface
|
||||
.replace(/^\s*[-–—:]+\s*/g, '')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
function buildTailoringPrompt(profile: Record<string, unknown>, jd: string): string {
|
||||
// Extract only needed parts of profile to save tokens
|
||||
const relevantProfile = {
|
||||
basics: {
|
||||
name: (profile as any).basics?.name,
|
||||
label: (profile as any).basics?.label, // Original headline
|
||||
summary: (profile as any).basics?.summary,
|
||||
},
|
||||
skills: (profile as any).sections?.skills || (profile as any).skills,
|
||||
projects: (profile as any).sections?.projects?.items?.map((p: any) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
keywords: p.keywords
|
||||
})),
|
||||
experience: (profile as any).sections?.experience?.items?.map((e: any) => ({
|
||||
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.slice(0, 3000)} ... (truncated if too long)
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface Job {
|
||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||
suitabilityReason: string | null; // AI explanation
|
||||
tailoredSummary: string | null; // Generated resume summary
|
||||
tailoredHeadline: string | null; // Generated resume headline
|
||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
notionPageId: string | null; // Notion page ID if synced
|
||||
@@ -133,6 +135,8 @@ export interface UpdateJobInput {
|
||||
suitabilityScore?: number;
|
||||
suitabilityReason?: string;
|
||||
tailoredSummary?: string;
|
||||
tailoredHeadline?: string;
|
||||
tailoredSkills?: string;
|
||||
selectedProjectIds?: string;
|
||||
pdfPath?: string;
|
||||
notionPageId?: string;
|
||||
|
||||
Reference in New Issue
Block a user