allowing tailoring of resume before being manually built
This commit is contained in:
parent
4e17371990
commit
cd5ec25c3b
@ -174,6 +174,7 @@ export const App: React.FC = () => {
|
||||
onApply={handleApply}
|
||||
onReject={handleReject}
|
||||
onProcess={handleProcess}
|
||||
onUpdate={loadJobs}
|
||||
processingJobId={processingJobId}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
181
orchestrator/src/client/components/TailoringEditor.tsx
Normal file
181
orchestrator/src/client/components/TailoringEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -6,3 +6,5 @@ export { JobCard } from './JobCard';
|
||||
export { JobTable } from './JobTable';
|
||||
export { JobList } from './JobList';
|
||||
export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user