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}
|
onApply={handleApply}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
onProcess={handleProcess}
|
onProcess={handleProcess}
|
||||||
|
onUpdate={loadJobs}
|
||||||
processingJobId={processingJobId}
|
processingJobId={processingJobId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</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> {
|
export async function markAsApplied(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -95,11 +108,16 @@ export async function runPipeline(config?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings API
|
// Settings & Profile API
|
||||||
export async function getSettings(): Promise<AppSettings> {
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
return fetchApi<AppSettings>('/settings');
|
return fetchApi<AppSettings>('/settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> {
|
||||||
|
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function updateSettings(update: {
|
export async function updateSettings(update: {
|
||||||
model?: string | null
|
model?: string | null
|
||||||
pipelineWebhookUrl?: string | null
|
pipelineWebhookUrl?: string | null
|
||||||
|
|||||||
@ -26,12 +26,14 @@ import { cn } from "@/lib/utils";
|
|||||||
import type { Job, JobStatus, JobSource } from "../../shared/types";
|
import type { Job, JobStatus, JobSource } from "../../shared/types";
|
||||||
import { JobCard } from "./JobCard";
|
import { JobCard } from "./JobCard";
|
||||||
import { JobTable, type JobSort } from "./JobTable";
|
import { JobTable, type JobSort } from "./JobTable";
|
||||||
|
import { TailoringEditor } from "./TailoringEditor";
|
||||||
|
|
||||||
interface JobListProps {
|
interface JobListProps {
|
||||||
jobs: Job[];
|
jobs: Job[];
|
||||||
onApply: (id: string) => void | Promise<void>;
|
onApply: (id: string) => void | Promise<void>;
|
||||||
onReject: (id: string) => void | Promise<void>;
|
onReject: (id: string) => void | Promise<void>;
|
||||||
onProcess: (id: string) => void | Promise<void>;
|
onProcess: (id: string) => void | Promise<void>;
|
||||||
|
onUpdate: () => void | Promise<void>;
|
||||||
processingJobId: string | null;
|
processingJobId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,6 +188,7 @@ export const JobList: React.FC<JobListProps> = ({
|
|||||||
onApply,
|
onApply,
|
||||||
onReject,
|
onReject,
|
||||||
onProcess,
|
onProcess,
|
||||||
|
onUpdate,
|
||||||
processingJobId,
|
processingJobId,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
|
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
|
||||||
@ -408,6 +411,8 @@ export const JobList: React.FC<JobListProps> = ({
|
|||||||
onHighlightChange={setHighlightedJobId}
|
onHighlightChange={setHighlightedJobId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TailoringEditor job={highlightedJob} onUpdate={onUpdate} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-base">Job description</CardTitle>
|
<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 { JobTable } from './JobTable';
|
||||||
export { JobList } from './JobList';
|
export { JobList } from './JobList';
|
||||||
export { PipelineProgress } from './PipelineProgress';
|
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 jobsRepo from '../repositories/jobs.js';
|
||||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||||
import * as settingsRepo from '../repositories/settings.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 { createNotionEntry } from '../services/notion.js';
|
||||||
import { clearDatabase } from '../db/clear.js';
|
import { clearDatabase } from '../db/clear.js';
|
||||||
import {
|
import {
|
||||||
@ -106,6 +106,7 @@ const updateJobSchema = z.object({
|
|||||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||||
suitabilityReason: z.string().optional(),
|
suitabilityReason: z.string().optional(),
|
||||||
tailoredSummary: z.string().optional(),
|
tailoredSummary: z.string().optional(),
|
||||||
|
selectedProjectIds: z.string().optional(),
|
||||||
pdfPath: 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)
|
* 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
|
// Database Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -132,6 +132,7 @@ const migrations = [
|
|||||||
`ALTER TABLE jobs ADD COLUMN company_reviews_count INTEGER`,
|
`ALTER TABLE jobs ADD COLUMN company_reviews_count INTEGER`,
|
||||||
`ALTER TABLE jobs ADD COLUMN vacancy_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 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_status ON jobs(status)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
`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'),
|
suitabilityScore: real('suitability_score'),
|
||||||
suitabilityReason: text('suitability_reason'),
|
suitabilityReason: text('suitability_reason'),
|
||||||
tailoredSummary: text('tailored_summary'),
|
tailoredSummary: text('tailored_summary'),
|
||||||
|
selectedProjectIds: text('selected_project_ids'),
|
||||||
pdfPath: text('pdf_path'),
|
pdfPath: text('pdf_path'),
|
||||||
notionPageId: text('notion_page_id'),
|
notionPageId: text('notion_page_id'),
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
|
|||||||
import { scoreJobSuitability } from '../services/scorer.js';
|
import { scoreJobSuitability } from '../services/scorer.js';
|
||||||
import { generateSummary } from '../services/summary.js';
|
import { generateSummary } from '../services/summary.js';
|
||||||
import { generatePdf } from '../services/pdf.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 jobsRepo from '../repositories/jobs.js';
|
||||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||||
import * as settingsRepo from '../repositories/settings.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(
|
export async function processJob(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
@ -380,56 +493,14 @@ export async function processJob(
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
console.log(`📝 Processing job ${jobId}...`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await jobsRepo.getJobById(jobId);
|
// Step 1: Summarize & Select Projects
|
||||||
if (!job) {
|
const sumResult = await summarizeJob(jobId, options);
|
||||||
return { success: false, error: 'Job not found' };
|
if (!sumResult.success) return sumResult;
|
||||||
}
|
|
||||||
|
|
||||||
if (job.status !== 'discovered' && job.status !== 'ready') {
|
// Step 2: Generate PDF
|
||||||
return { success: false, error: `Job cannot be processed from status: ${job.status}` };
|
const pdfResult = await generateFinalPdf(jobId);
|
||||||
}
|
return pdfResult;
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@ -241,6 +241,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
|||||||
suitabilityScore: row.suitabilityScore,
|
suitabilityScore: row.suitabilityScore,
|
||||||
suitabilityReason: row.suitabilityReason,
|
suitabilityReason: row.suitabilityReason,
|
||||||
tailoredSummary: row.tailoredSummary,
|
tailoredSummary: row.tailoredSummary,
|
||||||
|
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||||
pdfPath: row.pdfPath,
|
pdfPath: row.pdfPath,
|
||||||
notionPageId: row.notionPageId,
|
notionPageId: row.notionPageId,
|
||||||
jobType: row.jobType ?? null,
|
jobType: row.jobType ?? null,
|
||||||
|
|||||||
@ -34,12 +34,14 @@ export interface PdfResult {
|
|||||||
* @param tailoredSummary - The AI-generated summary to inject
|
* @param tailoredSummary - The AI-generated summary to inject
|
||||||
* @param jobDescription - Job description text for project selection
|
* @param jobDescription - Job description text for project selection
|
||||||
* @param baseResumePath - Path to the base resume JSON (optional)
|
* @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(
|
export async function generatePdf(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
tailoredSummary: string,
|
tailoredSummary: string,
|
||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
baseResumePath?: string
|
baseResumePath?: string,
|
||||||
|
selectedProjectIds?: string | null
|
||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||||
|
|
||||||
@ -61,24 +63,31 @@ export async function generatePdf(
|
|||||||
baseResume.basics.summary = tailoredSummary;
|
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 {
|
try {
|
||||||
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
|
let selectedSet: Set<string>;
|
||||||
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
|
|
||||||
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
|
||||||
|
|
||||||
const locked = resumeProjects.lockedProjectIds;
|
if (selectedProjectIds) {
|
||||||
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
|
selectedSet = new Set(selectedProjectIds.split(',').map(s => s.trim()).filter(Boolean));
|
||||||
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
} else {
|
||||||
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
|
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
|
||||||
|
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
|
||||||
|
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
const picked = await pickProjectIdsForJob({
|
const locked = resumeProjects.lockedProjectIds;
|
||||||
jobDescription,
|
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
|
||||||
eligibleProjects,
|
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||||
desiredCount,
|
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 projectsSection = (baseResume as any)?.sections?.projects;
|
||||||
const projectItems = projectsSection?.items;
|
const projectItems = projectsSection?.items;
|
||||||
if (Array.isArray(projectItems)) {
|
if (Array.isArray(projectItems)) {
|
||||||
@ -90,8 +99,8 @@ export async function generatePdf(
|
|||||||
}
|
}
|
||||||
projectsSection.visible = selectedSet.size > 0;
|
projectsSection.visible = selectedSet.size > 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Non-fatal: fall back to whatever visibility is in base.json
|
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write modified resume to temp file
|
// Write modified resume to temp file
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export interface Job {
|
|||||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||||
suitabilityReason: string | null; // AI explanation
|
suitabilityReason: string | null; // AI explanation
|
||||||
tailoredSummary: string | null; // Generated resume summary
|
tailoredSummary: string | null; // Generated resume summary
|
||||||
|
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||||
pdfPath: string | null; // Path to generated PDF
|
pdfPath: string | null; // Path to generated PDF
|
||||||
notionPageId: string | null; // Notion page ID if synced
|
notionPageId: string | null; // Notion page ID if synced
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ export interface UpdateJobInput {
|
|||||||
suitabilityScore?: number;
|
suitabilityScore?: number;
|
||||||
suitabilityReason?: string;
|
suitabilityReason?: string;
|
||||||
tailoredSummary?: string;
|
tailoredSummary?: string;
|
||||||
|
selectedProjectIds?: string;
|
||||||
pdfPath?: string;
|
pdfPath?: string;
|
||||||
notionPageId?: string;
|
notionPageId?: string;
|
||||||
appliedAt?: string;
|
appliedAt?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user