allowing tailoring of resume before being manually built

This commit is contained in:
DaKheera47 2026-01-06 23:06:19 +00:00
parent 4e17371990
commit cd5ec25c3b
12 changed files with 417 additions and 68 deletions

View File

@ -174,6 +174,7 @@ export const App: React.FC = () => {
onApply={handleApply}
onReject={handleReject}
onProcess={handleProcess}
onUpdate={loadJobs}
processingJobId={processingJobId}
/>
</main>

View File

@ -63,6 +63,19 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro
});
}
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
const query = options?.force ? '?force=1' : '';
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
method: 'POST',
});
}
export async function generateJobPdf(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/generate-pdf`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
@ -95,11 +108,16 @@ export async function runPipeline(config?: {
});
}
// Settings API
// Settings & Profile API
export async function getSettings(): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings');
}
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> {
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
}
export async function updateSettings(update: {
model?: string | null
pipelineWebhookUrl?: string | null

View File

@ -26,12 +26,14 @@ import { cn } from "@/lib/utils";
import type { Job, JobStatus, JobSource } from "../../shared/types";
import { JobCard } from "./JobCard";
import { JobTable, type JobSort } from "./JobTable";
import { TailoringEditor } from "./TailoringEditor";
interface JobListProps {
jobs: Job[];
onApply: (id: string) => void | Promise<void>;
onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>;
onUpdate: () => void | Promise<void>;
processingJobId: string | null;
}
@ -186,6 +188,7 @@ export const JobList: React.FC<JobListProps> = ({
onApply,
onReject,
onProcess,
onUpdate,
processingJobId,
}) => {
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
@ -408,6 +411,8 @@ export const JobList: React.FC<JobListProps> = ({
onHighlightChange={setHighlightedJobId}
/>
<TailoringEditor job={highlightedJob} onUpdate={onUpdate} />
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-base">Job description</CardTitle>

View File

@ -0,0 +1,181 @@
import React, { useEffect, useState } from "react";
import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import * as api from "../api";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
interface TailoringEditorProps {
job: Job;
onUpdate: () => void | Promise<void>;
}
export const TailoringEditor: React.FC<TailoringEditorProps> = ({ job, onUpdate }) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
// Load project catalog
api.getProfileProjects().then(setCatalog).catch(console.error);
// Set initial selection
if (job.selectedProjectIds) {
setSelectedIds(new Set(job.selectedProjectIds.split(',').filter(Boolean)));
}
}, [job.selectedProjectIds]);
useEffect(() => {
setSummary(job.tailoredSummary || "");
}, [job.tailoredSummary]);
const handleToggleProject = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const handleSave = async () => {
try {
setIsSaving(true);
await api.updateJob(job.id, {
tailoredSummary: summary,
selectedProjectIds: Array.from(selectedIds).join(','),
});
toast.success("Changes saved");
await onUpdate();
} catch (error) {
toast.error("Failed to save changes");
} finally {
setIsSaving(false);
}
};
const handleSummarize = async () => {
try {
setIsSummarizing(true);
const updatedJob = await api.summarizeJob(job.id, { force: true });
setSummary(updatedJob.tailoredSummary || "");
if (updatedJob.selectedProjectIds) {
setSelectedIds(new Set(updatedJob.selectedProjectIds.split(',').filter(Boolean)));
}
toast.success("AI Summary & Projects generated");
await onUpdate();
} catch (error) {
toast.error("AI summarization failed");
} finally {
setIsSummarizing(false);
}
};
const handleGeneratePdf = async () => {
try {
setIsGeneratingPdf(true);
// Save current state first to ensure PDF uses latest
await api.updateJob(job.id, {
tailoredSummary: summary,
selectedProjectIds: Array.from(selectedIds).join(','),
});
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
await onUpdate();
} catch (error) {
toast.error("PDF generation failed");
} finally {
setIsGeneratingPdf(false);
}
};
const maxProjects = 3; // Example limit, could come from settings
const tooManyProjects = selectedIds.size > maxProjects;
return (
<Card className="border-primary/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-bold">Tailoring Editor</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSummarize}
disabled={isSummarizing || isGeneratingPdf || isSaving}
>
{isSummarizing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
AI Summarize
</Button>
<Button
size="sm"
onClick={handleGeneratePdf}
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
>
{isGeneratingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
Generate PDF
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tailored Summary</label>
<textarea
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="AI will generate this, or you can write your own..."
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Selected Projects</label>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" />
Warning: More than {maxProjects} projects might make the resume too long.
</span>
)}
</div>
<div className="grid gap-2 max-h-[300px] overflow-auto pr-2">
{catalog.map((project) => (
<div
key={project.id}
className="flex items-start gap-3 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
>
<Checkbox
id={`project-${project.id}`}
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleProject(project.id)}
className="mt-1"
/>
<label
htmlFor={`project-${project.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="font-semibold">{project.name}</span>
<span className="text-xs text-muted-foreground line-clamp-2">{project.description}</span>
</label>
</div>
))}
</div>
</div>
<div className="flex justify-end border-t pt-4">
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
Save Selection
</Button>
</div>
</CardContent>
</Card>
);
};

View File

@ -6,3 +6,5 @@ export { JobCard } from './JobCard';
export { JobTable } from './JobTable';
export { JobList } from './JobList';
export { PipelineProgress } from './PipelineProgress';
export { TailoringEditor } from './TailoringEditor';

View File

@ -7,7 +7,7 @@ import { z } from 'zod';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
import { runPipeline, processJob, summarizeJob, generateFinalPdf, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
import { createNotionEntry } from '../services/notion.js';
import { clearDatabase } from '../db/clear.js';
import {
@ -106,6 +106,7 @@ const updateJobSchema = z.object({
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
tailoredSummary: z.string().optional(),
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
});
@ -128,6 +129,47 @@ apiRouter.patch('/jobs/:id', async (req: Request, res: Response) => {
}
});
/**
* POST /api/jobs/:id/summarize - Generate AI summary and suggest projects
*/
apiRouter.post('/jobs/:id/summarize', async (req: Request, res: Response) => {
try {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === '1' || forceRaw === 'true';
const result = await summarizeJob(req.params.id, { force });
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
*/
apiRouter.post('/jobs/:id/generate-pdf', async (req: Request, res: Response) => {
try {
const result = await generateFinalPdf(req.params.id);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/process - Process a single job (generate summary + PDF)
*/
@ -653,6 +695,21 @@ apiRouter.delete('/jobs/status/:status', async (req: Request, res: Response) =>
}
});
/**
* GET /api/profile/projects - Get all projects available in the base resume
*/
apiRouter.get('/profile/projects', async (req: Request, res: Response) => {
try {
const profile = await loadResumeProfile();
const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
// ============================================================================
// Database Management
// ============================================================================

View File

@ -132,6 +132,7 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN company_reviews_count INTEGER`,
`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`,
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,

View File

@ -59,6 +59,7 @@ export const jobs = sqliteTable('jobs', {
suitabilityScore: real('suitability_score'),
suitabilityReason: text('suitability_reason'),
tailoredSummary: text('tailored_summary'),
selectedProjectIds: text('selected_project_ids'),
pdfPath: text('pdf_path'),
notionPageId: text('notion_page_id'),

View File

@ -16,6 +16,9 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
import { scoreJobSuitability } from '../services/scorer.js';
import { generateSummary } from '../services/summary.js';
import { generatePdf } from '../services/pdf.js';
import { getSetting } from '../repositories/settings.js';
import { pickProjectIdsForJob } from '../services/projectSelection.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
@ -371,7 +374,117 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
}
/**
* Process a single job (for manual processing).
* Step 1: Generate AI summary and suggest projects.
*/
export async function summarizeJob(
jobId: string,
options?: { force?: boolean }
): Promise<{
success: boolean;
error?: string;
}> {
console.log(`📝 Summarizing job ${jobId}...`);
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: 'Job not found' };
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
// 1. Generate Summary
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;
}
}
// 2. Suggest Projects
let selectedProjectIds = job.selectedProjectIds;
if (!selectedProjectIds || options?.force) {
console.log(' Suggesting projects...');
try {
const { catalog, selectionItems } = extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
const picked = await pickProjectIdsForJob({
jobDescription: job.jobDescription || '',
eligibleProjects,
desiredCount,
});
selectedProjectIds = [...locked, ...picked].join(',');
} catch (err) {
console.warn(' ⚠️ Failed to suggest projects, leaving empty');
}
}
await jobsRepo.updateJob(job.id, {
tailoredSummary: tailoredSummary ?? undefined,
selectedProjectIds: selectedProjectIds ?? undefined,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
}
}
/**
* Step 2: Generate PDF using current summary and project selection.
*/
export async function generateFinalPdf(
jobId: string
): Promise<{
success: boolean;
error?: string;
}> {
console.log(`📄 Generating final PDF for job ${jobId}...`);
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: 'Job not found' };
// Mark as processing
await jobsRepo.updateJob(job.id, { status: 'processing' });
const pdfResult = await generatePdf(
job.id,
job.tailoredSummary || '',
job.jobDescription || '',
DEFAULT_PROFILE_PATH,
job.selectedProjectIds
);
if (!pdfResult.success) {
// Revert status if failed
await jobsRepo.updateJob(job.id, { status: 'discovered' });
return { success: false, error: pdfResult.error };
}
await jobsRepo.updateJob(job.id, {
status: 'ready',
pdfPath: pdfResult.pdfPath,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
}
}
/**
* Process a single job (runs both steps in sequence).
*/
export async function processJob(
jobId: string,
@ -380,56 +493,14 @@ export async function processJob(
success: boolean;
error?: string;
}> {
console.log(`📝 Processing job ${jobId}...`);
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) {
return { success: false, error: 'Job not found' };
}
// Step 1: Summarize & Select Projects
const sumResult = await summarizeJob(jobId, options);
if (!sumResult.success) return sumResult;
if (job.status !== 'discovered' && job.status !== 'ready') {
return { success: false, error: `Job cannot be processed from status: ${job.status}` };
}
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
// Mark as processing
await jobsRepo.updateJob(job.id, { status: 'processing' });
// Generate summary (AI) if missing
if (!job.tailoredSummary) {
console.log(' Generating summary...');
const summaryResult = await generateSummary(
job.jobDescription || '',
profile
);
if (summaryResult.success) {
await jobsRepo.updateJob(job.id, {
tailoredSummary: summaryResult.summary,
});
job.tailoredSummary = summaryResult.summary ?? null;
}
}
// Generate PDF
console.log(' Generating PDF...');
const pdfResult = await generatePdf(
job.id,
job.tailoredSummary || '',
job.jobDescription || '',
DEFAULT_PROFILE_PATH
);
// Mark as ready
await jobsRepo.updateJob(job.id, {
status: 'ready',
pdfPath: pdfResult.pdfPath ?? undefined,
});
console.log(' ✅ Done!');
return { success: true };
// Step 2: Generate PDF
const pdfResult = await generateFinalPdf(jobId);
return pdfResult;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';

View File

@ -241,6 +241,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
suitabilityScore: row.suitabilityScore,
suitabilityReason: row.suitabilityReason,
tailoredSummary: row.tailoredSummary,
selectedProjectIds: row.selectedProjectIds ?? null,
pdfPath: row.pdfPath,
notionPageId: row.notionPageId,
jobType: row.jobType ?? null,

View File

@ -34,12 +34,14 @@ export interface PdfResult {
* @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)
*/
export async function generatePdf(
jobId: string,
tailoredSummary: string,
jobDescription: string,
baseResumePath?: string
baseResumePath?: string,
selectedProjectIds?: string | null
): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId}...`);
@ -61,24 +63,31 @@ export async function generatePdf(
baseResume.basics.summary = tailoredSummary;
}
// Select projects (locked + AI-picked) and set visibility for RXResume
// Select projects (manual override OR locked + AI-picked) and set visibility for RXResume
try {
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
let selectedSet: Set<string>;
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
if (selectedProjectIds) {
selectedSet = new Set(selectedProjectIds.split(',').map(s => s.trim()).filter(Boolean));
} else {
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
const picked = await pickProjectIdsForJob({
jobDescription,
eligibleProjects,
desiredCount,
});
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
const picked = await pickProjectIdsForJob({
jobDescription,
eligibleProjects,
desiredCount,
});
selectedSet = new Set([...locked, ...picked]);
}
const selectedSet = new Set([...locked, ...picked]);
const projectsSection = (baseResume as any)?.sections?.projects;
const projectItems = projectsSection?.items;
if (Array.isArray(projectItems)) {
@ -90,8 +99,8 @@ export async function generatePdf(
}
projectsSection.visible = selectedSet.size > 0;
}
} catch {
// Non-fatal: fall back to whatever visibility is in base.json
} catch (err) {
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
}
// Write modified resume to temp file

View File

@ -44,6 +44,7 @@ export interface Job {
suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation
tailoredSummary: string | null; // Generated resume summary
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF
notionPageId: string | null; // Notion page ID if synced
@ -131,6 +132,7 @@ export interface UpdateJobInput {
suitabilityScore?: number;
suitabilityReason?: string;
tailoredSummary?: string;
selectedProjectIds?: string;
pdfPath?: string;
notionPageId?: string;
appliedAt?: string;