Merge pull request #3 from DaKheera47/autochoose-projects
Autochoose projects
This commit is contained in:
commit
8633eb029e
@ -35,7 +35,8 @@ services:
|
||||
|
||||
# JobSpy (Indeed/LinkedIn scraping) - optional
|
||||
- JOBSPY_SITES=${JOBSPY_SITES:-indeed,linkedin}
|
||||
- JOBSPY_SEARCH_TERM=${JOBSPY_SEARCH_TERM:-web developer}
|
||||
# Preferred: pipe-separated list, e.g. "web developer|frontend developer|react developer"
|
||||
- JOBSPY_SEARCH_TERMS=${JOBSPY_SEARCH_TERMS:-web developer|frontend developer|react developer}
|
||||
- JOBSPY_LOCATION=${JOBSPY_LOCATION:-UK}
|
||||
- JOBSPY_RESULTS_WANTED=${JOBSPY_RESULTS_WANTED:-200}
|
||||
- JOBSPY_HOURS_OLD=${JOBSPY_HOURS_OLD:-72}
|
||||
|
||||
1377
orchestrator/package-lock.json
generated
1377
orchestrator/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,9 +18,9 @@
|
||||
"pipeline:run": "tsx src/server/pipeline/run.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@ -34,6 +34,8 @@
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
JobSource,
|
||||
PipelineRun,
|
||||
AppSettings,
|
||||
ResumeProjectsSettings,
|
||||
} from '../../shared/types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
@ -102,6 +103,7 @@ export async function updateSettings(update: {
|
||||
model?: string | null
|
||||
pipelineWebhookUrl?: string | null
|
||||
jobCompleteWebhookUrl?: string | null
|
||||
resumeProjects?: ResumeProjectsSettings | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@ -32,6 +32,8 @@ interface JobCardProps {
|
||||
onReject: (id: string) => void | Promise<void>;
|
||||
onProcess: (id: string) => void | Promise<void>;
|
||||
isProcessing: boolean;
|
||||
highlightedJobId?: string | null;
|
||||
onHighlightChange?: (jobId: string | null) => void;
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
@ -55,6 +57,8 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
onReject,
|
||||
onProcess,
|
||||
isProcessing,
|
||||
highlightedJobId,
|
||||
onHighlightChange,
|
||||
}) => {
|
||||
const sourceLabel: Record<Job["source"], string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
@ -70,6 +74,7 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
const pdfHref = `/pdfs/resume_${job.id}.pdf`;
|
||||
const deadline = formatDate(job.deadline);
|
||||
const isHighlighted = highlightedJobId === job.id;
|
||||
|
||||
const handleCopyInfo = async () => {
|
||||
try {
|
||||
@ -149,6 +154,16 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
Copy info
|
||||
</Button>
|
||||
|
||||
{onHighlightChange && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onHighlightChange(isHighlighted ? null : job.id)}
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPdf && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUpDown, LayoutGrid, Search, Table2 } from "lucide-react";
|
||||
import { ArrowUpDown, LayoutGrid, Search, Table2, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -170,6 +172,8 @@ const jobMatchesQuery = (job: Job, query: string) => {
|
||||
return haystack.includes(normalized);
|
||||
};
|
||||
|
||||
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
export const JobList: React.FC<JobListProps> = ({
|
||||
jobs,
|
||||
onApply,
|
||||
@ -182,6 +186,8 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
|
||||
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set());
|
||||
const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null);
|
||||
const [highlightedJobId, setHighlightedJobId] = useState<string | null>(null);
|
||||
const [isHighlightVisible, setIsHighlightVisible] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY);
|
||||
@ -204,6 +210,27 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
setSelectedJobIds(new Set());
|
||||
}, [activeTab, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedJobId) return;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
setIsHighlightVisible(false);
|
||||
const raf = requestAnimationFrame(() => setIsHighlightVisible(true));
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setHighlightedJobId(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
setIsHighlightVisible(false);
|
||||
};
|
||||
}, [highlightedJobId]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
@ -250,6 +277,17 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
}, [jobsForTab, searchQuery, sort]);
|
||||
|
||||
const activeTabJobs = visibleJobsForTab.get(activeTab) ?? [];
|
||||
const highlightedJob = useMemo(
|
||||
() => (highlightedJobId ? jobs.find((job) => job.id === highlightedJobId) ?? null : null),
|
||||
[highlightedJobId, jobs],
|
||||
);
|
||||
|
||||
const highlightedJobDescription = useMemo(() => {
|
||||
if (!highlightedJob) return "No description available.";
|
||||
const jd = highlightedJob.jobDescription || "No description available.";
|
||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
||||
return jd;
|
||||
}, [highlightedJob]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedJobIds((current) => {
|
||||
@ -307,11 +345,75 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as FilterTab)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<>
|
||||
{highlightedJob && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-background/30 backdrop-blur-md backdrop-saturate-150 transition-opacity duration-200 ease-out",
|
||||
isHighlightVisible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
onClick={() => setHighlightedJobId(null)}
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8"
|
||||
onClick={() => setHighlightedJobId(null)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-4xl space-y-4 transition-all duration-200 ease-out",
|
||||
isHighlightVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2",
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-muted-foreground">Highlighted job</div>
|
||||
<div className="truncate text-base font-semibold">{highlightedJob.title}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setHighlightedJobId(null)}
|
||||
aria-label="Close highlight"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<JobCard
|
||||
job={highlightedJob}
|
||||
onApply={onApply}
|
||||
onReject={onReject}
|
||||
onProcess={onProcess}
|
||||
isProcessing={processingJobId === highlightedJob.id}
|
||||
highlightedJobId={highlightedJobId}
|
||||
onHighlightChange={setHighlightedJobId}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base">Job description</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">Press Esc or click outside to exit highlight.</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[60vh] overflow-auto text-sm text-muted-foreground">
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{highlightedJobDescription}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as FilterTab)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TabsList className="w-full sm:w-auto h-9">
|
||||
@ -500,6 +602,8 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
onReject={onReject}
|
||||
onProcess={onProcess}
|
||||
processingJobId={processingJobId}
|
||||
highlightedJobId={highlightedJobId}
|
||||
onHighlightChange={setHighlightedJobId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -514,6 +618,8 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
onReject={onReject}
|
||||
onProcess={onProcess}
|
||||
isProcessing={processingJobId === job.id}
|
||||
highlightedJobId={highlightedJobId}
|
||||
onHighlightChange={setHighlightedJobId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -523,6 +629,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -59,6 +59,8 @@ export interface JobTableProps {
|
||||
onReject: (id: string) => void | Promise<void>;
|
||||
onProcess: (id: string) => void | Promise<void>;
|
||||
processingJobId: string | null;
|
||||
highlightedJobId?: string | null;
|
||||
onHighlightChange?: (jobId: string | null) => void;
|
||||
}
|
||||
|
||||
const sourceLabel: Record<Job["source"], string> = {
|
||||
@ -135,6 +137,8 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
onReject,
|
||||
onProcess,
|
||||
processingJobId,
|
||||
highlightedJobId,
|
||||
onHighlightChange,
|
||||
}) => {
|
||||
const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0);
|
||||
const allSelected = jobs.length > 0 && selectedCount === jobs.length;
|
||||
@ -215,6 +219,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
const canReject = ["discovered", "ready"].includes(job.status);
|
||||
const isProcessing = processingJobId === job.id;
|
||||
const isSelected = selectedJobIds.has(job.id);
|
||||
const isHighlighted = highlightedJobId === job.id;
|
||||
|
||||
return (
|
||||
<TableRow key={job.id} data-state={isSelected ? "selected" : undefined}>
|
||||
@ -290,6 +295,14 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
Copy info
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onHighlightChange && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onHighlightChange(isHighlighted ? null : job.id)}
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasPdf && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@ -7,16 +7,41 @@ import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import type { AppSettings } from "../../shared/types"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { AppSettings, ResumeProjectsSettings } from "../../shared/types"
|
||||
import * as api from "../api"
|
||||
|
||||
function arraysEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||
return (
|
||||
a.maxProjects === b.maxProjects &&
|
||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const int = Math.floor(value)
|
||||
if (Number.isNaN(int)) return min
|
||||
return Math.min(max, Math.max(min, int))
|
||||
}
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [modelDraft, setModelDraft] = useState("")
|
||||
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
||||
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
||||
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
@ -31,6 +56,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setModelDraft(data.overrideModel ?? "")
|
||||
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(data.resumeProjects)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||
@ -55,9 +81,12 @@ export const SettingsPage: React.FC = () => {
|
||||
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
||||
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
||||
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
const maxProjectsTotal = profileProjects.length
|
||||
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!settings) return false
|
||||
if (!settings || !resumeProjectsDraft) return false
|
||||
const next = modelDraft.trim()
|
||||
const current = (overrideModel ?? "").trim()
|
||||
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
||||
@ -67,7 +96,8 @@ export const SettingsPage: React.FC = () => {
|
||||
return (
|
||||
next !== current ||
|
||||
nextWebhook !== currentWebhook ||
|
||||
nextJobCompleteWebhook !== currentJobCompleteWebhook
|
||||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
|
||||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects)
|
||||
)
|
||||
}, [
|
||||
settings,
|
||||
@ -77,24 +107,30 @@ export const SettingsPage: React.FC = () => {
|
||||
overrideModel,
|
||||
overridePipelineWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
resumeProjectsDraft,
|
||||
])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return
|
||||
if (!settings || !resumeProjectsDraft) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const trimmed = modelDraft.trim()
|
||||
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
||||
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
||||
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
|
||||
? null
|
||||
: resumeProjectsDraft
|
||||
const updated = await api.updateSettings({
|
||||
model: trimmed.length > 0 ? trimmed : null,
|
||||
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
||||
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft(updated.overrideModel ?? "")
|
||||
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
toast.success("Settings saved")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||
@ -107,11 +143,17 @@ export const SettingsPage: React.FC = () => {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null })
|
||||
const updated = await api.updateSettings({
|
||||
model: null,
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft("")
|
||||
setPipelineWebhookUrlDraft("")
|
||||
setJobCompleteWebhookUrlDraft("")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
toast.success("Reset to default")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||
@ -230,6 +272,122 @@ export const SettingsPage: React.FC = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Resume Projects</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
|
||||
@ -10,6 +10,12 @@ import * as settingsRepo from '../repositories/settings.js';
|
||||
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||
import { createNotionEntry } from '../services/notion.js';
|
||||
import { clearDatabase } from '../db/clear.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
loadResumeProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../services/resumeProjects.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
@ -224,6 +230,11 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -236,6 +247,7 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -248,6 +260,11 @@ const updateSettingsSchema = z.object({
|
||||
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||
resumeProjects: z.object({
|
||||
maxProjects: z.number().int().min(0).max(50),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
}).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -272,6 +289,20 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
||||
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
||||
}
|
||||
|
||||
if ('resumeProjects' in input) {
|
||||
const resumeProjects = input.resumeProjects ?? null;
|
||||
|
||||
if (resumeProjects === null) {
|
||||
await settingsRepo.setSetting('resumeProjects', null);
|
||||
} else {
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
@ -284,6 +315,11 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -296,6 +332,7 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -317,6 +317,7 @@ export async function processJob(jobId: string): Promise<{
|
||||
const pdfResult = await generatePdf(
|
||||
job.id,
|
||||
job.tailoredSummary || '',
|
||||
job.jobDescription || '',
|
||||
DEFAULT_PROFILE_PATH
|
||||
);
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ const { settings } = schema
|
||||
export type SettingKey = 'model'
|
||||
| 'pipelineWebhookUrl'
|
||||
| 'jobCompleteWebhookUrl'
|
||||
| 'resumeProjects'
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { readFile, mkdir } from 'fs/promises';
|
||||
import { readFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { CreateJobInput, JobSource } from '../../shared/types.js';
|
||||
@ -110,7 +110,7 @@ function formatSalary(params: {
|
||||
|
||||
export interface RunJobSpyOptions {
|
||||
sites?: Array<JobSource>;
|
||||
searchTerm?: string;
|
||||
searchTerms?: string[];
|
||||
location?: string;
|
||||
resultsWanted?: number;
|
||||
hoursOld?: number;
|
||||
@ -129,108 +129,71 @@ export async function runJobSpy(options: RunJobSpyOptions = {}): Promise<JobSpyR
|
||||
const outputDir = join(dataDir, 'imports');
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const outputCsv = join(outputDir, 'jobspy_jobs.csv');
|
||||
const outputJson = join(outputDir, 'jobspy_jobs.json');
|
||||
|
||||
const sites = (options.sites ?? ['indeed', 'linkedin'])
|
||||
.filter((s) => s === 'indeed' || s === 'linkedin')
|
||||
.join(',');
|
||||
|
||||
const searchTerms = resolveSearchTerms(options);
|
||||
if (searchTerms.length === 0) {
|
||||
return { success: true, jobs: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
|
||||
cwd: JOBSPY_DIR,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
JOBSPY_SITES: sites || 'indeed,linkedin',
|
||||
JOBSPY_SEARCH_TERM: options.searchTerm ?? process.env.JOBSPY_SEARCH_TERM ?? 'web developer',
|
||||
JOBSPY_LOCATION: options.location ?? process.env.JOBSPY_LOCATION ?? 'UK',
|
||||
JOBSPY_RESULTS_WANTED: String(options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200),
|
||||
JOBSPY_HOURS_OLD: String(options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72),
|
||||
JOBSPY_COUNTRY_INDEED: options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? 'UK',
|
||||
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
|
||||
options.linkedinFetchDescription ?? process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ?? '1'
|
||||
),
|
||||
JOBSPY_OUTPUT_CSV: outputCsv,
|
||||
JOBSPY_OUTPUT_JSON: outputJson,
|
||||
},
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`JobSpy exited with code ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const raw = await readFile(outputJson, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seenJobUrls = new Set<string>();
|
||||
|
||||
for (const row of parsed) {
|
||||
const source = toJobSource(row.site);
|
||||
if (!source) continue;
|
||||
for (let i = 0; i < searchTerms.length; i++) {
|
||||
const searchTerm = searchTerms[i];
|
||||
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
|
||||
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
|
||||
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
|
||||
|
||||
const jobUrl = toStringOrNull(row.job_url);
|
||||
if (!jobUrl) continue;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
|
||||
cwd: JOBSPY_DIR,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
JOBSPY_SITES: sites || 'indeed,linkedin',
|
||||
JOBSPY_SEARCH_TERM: searchTerm,
|
||||
JOBSPY_LOCATION: options.location ?? process.env.JOBSPY_LOCATION ?? 'UK',
|
||||
JOBSPY_RESULTS_WANTED: String(options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200),
|
||||
JOBSPY_HOURS_OLD: String(options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72),
|
||||
JOBSPY_COUNTRY_INDEED: options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? 'UK',
|
||||
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
|
||||
options.linkedinFetchDescription ?? process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ?? '1'
|
||||
),
|
||||
JOBSPY_OUTPUT_CSV: outputCsv,
|
||||
JOBSPY_OUTPUT_JSON: outputJson,
|
||||
},
|
||||
});
|
||||
|
||||
const title = toStringOrNull(row.title) ?? 'Unknown Title';
|
||||
const employer = toStringOrNull(row.company) ?? 'Unknown Employer';
|
||||
|
||||
const jobUrlDirect = toStringOrNull(row.job_url_direct);
|
||||
const applicationLink = jobUrlDirect ?? jobUrl;
|
||||
|
||||
const minAmount = toNumberOrNull(row.min_amount);
|
||||
const maxAmount = toNumberOrNull(row.max_amount);
|
||||
const currency = toStringOrNull(row.currency);
|
||||
const interval = toStringOrNull(row.interval);
|
||||
|
||||
const salary = formatSalary({ minAmount, maxAmount, currency, interval });
|
||||
|
||||
jobs.push({
|
||||
source,
|
||||
sourceJobId: toStringOrNull(row.id) ?? undefined,
|
||||
jobUrlDirect: jobUrlDirect ?? undefined,
|
||||
datePosted: toStringOrNull(row.date_posted) ?? undefined,
|
||||
|
||||
title,
|
||||
employer,
|
||||
employerUrl: toStringOrNull(row.company_url) ?? undefined,
|
||||
jobUrl,
|
||||
applicationLink,
|
||||
location: toStringOrNull(row.location) ?? undefined,
|
||||
jobDescription: toStringOrNull(row.description) ?? undefined,
|
||||
salary: salary ?? undefined,
|
||||
|
||||
jobType: toStringOrNull(row.job_type) ?? undefined,
|
||||
salarySource: toStringOrNull(row.salary_source) ?? undefined,
|
||||
salaryInterval: interval ?? undefined,
|
||||
salaryMinAmount: minAmount ?? undefined,
|
||||
salaryMaxAmount: maxAmount ?? undefined,
|
||||
salaryCurrency: currency ?? undefined,
|
||||
isRemote: toBooleanOrNull(row.is_remote) ?? undefined,
|
||||
jobLevel: toStringOrNull(row.job_level) ?? undefined,
|
||||
jobFunction: toStringOrNull(row.job_function) ?? undefined,
|
||||
listingType: toStringOrNull(row.listing_type) ?? undefined,
|
||||
emails: toJsonStringOrNull(row.emails) ?? undefined,
|
||||
companyIndustry: toStringOrNull(row.company_industry) ?? undefined,
|
||||
companyLogo: toStringOrNull(row.company_logo) ?? undefined,
|
||||
companyUrlDirect: toStringOrNull(row.company_url_direct) ?? undefined,
|
||||
companyAddresses: toJsonStringOrNull(row.company_addresses) ?? undefined,
|
||||
companyNumEmployees: toStringOrNull(row.company_num_employees) ?? undefined,
|
||||
companyRevenue: toStringOrNull(row.company_revenue) ?? undefined,
|
||||
companyDescription: toStringOrNull(row.company_description) ?? undefined,
|
||||
skills: toJsonStringOrNull(row.skills) ?? undefined,
|
||||
experienceRange: toJsonStringOrNull(row.experience_range) ?? undefined,
|
||||
companyRating: toNumberOrNull(row.company_rating) ?? undefined,
|
||||
companyReviewsCount: toNumberOrNull(row.company_reviews_count) ?? undefined,
|
||||
vacancyCount: toNumberOrNull(row.vacancy_count) ?? undefined,
|
||||
workFromHomeType: toStringOrNull(row.work_from_home_type) ?? undefined,
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`JobSpy exited with code ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const raw = await readFile(outputJson, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
const mapped = mapJobSpyRows(parsed);
|
||||
|
||||
for (const job of mapped) {
|
||||
const url = job.jobUrl;
|
||||
if (seenJobUrls.has(url)) continue;
|
||||
seenJobUrls.add(url);
|
||||
jobs.push(job);
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(outputJson);
|
||||
await unlink(outputCsv);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
@ -239,3 +202,120 @@ export async function runJobSpy(options: RunJobSpyOptions = {}): Promise<JobSpyR
|
||||
return { success: false, jobs: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSearchTerms(options: RunJobSpyOptions): string[] {
|
||||
const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
|
||||
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS);
|
||||
const raw = fromOptions ?? fromEnv ?? ['web developer'];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const term of raw) {
|
||||
const normalized = term.trim();
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(normalized);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseSearchTermsEnv(raw: string | undefined): string[] | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const delimiter = trimmed.includes('|') ? '|' : trimmed.includes('\n') ? '\n' : ',';
|
||||
const split = trimmed.split(delimiter).map((t) => t.trim()).filter(Boolean);
|
||||
return split.length > 0 ? split : null;
|
||||
}
|
||||
|
||||
function slugForFilename(input: string): string {
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 40);
|
||||
return slug || 'term';
|
||||
}
|
||||
|
||||
function mapJobSpyRows(parsed: Array<Record<string, unknown>>): CreateJobInput[] {
|
||||
const jobs: CreateJobInput[] = [];
|
||||
|
||||
for (const row of parsed) {
|
||||
const source = toJobSource(row.site);
|
||||
if (!source) continue;
|
||||
|
||||
const jobUrl = toStringOrNull(row.job_url);
|
||||
if (!jobUrl) continue;
|
||||
|
||||
const title = toStringOrNull(row.title) ?? 'Unknown Title';
|
||||
const employer = toStringOrNull(row.company) ?? 'Unknown Employer';
|
||||
|
||||
const jobUrlDirect = toStringOrNull(row.job_url_direct);
|
||||
const applicationLink = jobUrlDirect ?? jobUrl;
|
||||
|
||||
const minAmount = toNumberOrNull(row.min_amount);
|
||||
const maxAmount = toNumberOrNull(row.max_amount);
|
||||
const currency = toStringOrNull(row.currency);
|
||||
const interval = toStringOrNull(row.interval);
|
||||
|
||||
const salary = formatSalary({ minAmount, maxAmount, currency, interval });
|
||||
|
||||
jobs.push({
|
||||
source,
|
||||
sourceJobId: toStringOrNull(row.id) ?? undefined,
|
||||
jobUrlDirect: jobUrlDirect ?? undefined,
|
||||
datePosted: toStringOrNull(row.date_posted) ?? undefined,
|
||||
|
||||
title,
|
||||
employer,
|
||||
employerUrl: toStringOrNull(row.company_url) ?? undefined,
|
||||
jobUrl,
|
||||
applicationLink,
|
||||
location: toStringOrNull(row.location) ?? undefined,
|
||||
jobDescription: toStringOrNull(row.description) ?? undefined,
|
||||
salary: salary ?? undefined,
|
||||
|
||||
jobType: toStringOrNull(row.job_type) ?? undefined,
|
||||
salarySource: toStringOrNull(row.salary_source) ?? undefined,
|
||||
salaryInterval: interval ?? undefined,
|
||||
salaryMinAmount: minAmount ?? undefined,
|
||||
salaryMaxAmount: maxAmount ?? undefined,
|
||||
salaryCurrency: currency ?? undefined,
|
||||
isRemote: toBooleanOrNull(row.is_remote) ?? undefined,
|
||||
jobLevel: toStringOrNull(row.job_level) ?? undefined,
|
||||
jobFunction: toStringOrNull(row.job_function) ?? undefined,
|
||||
listingType: toStringOrNull(row.listing_type) ?? undefined,
|
||||
emails: toJsonStringOrNull(row.emails) ?? undefined,
|
||||
companyIndustry: toStringOrNull(row.company_industry) ?? undefined,
|
||||
companyLogo: toStringOrNull(row.company_logo) ?? undefined,
|
||||
companyUrlDirect: toStringOrNull(row.company_url_direct) ?? undefined,
|
||||
companyAddresses: toJsonStringOrNull(row.company_addresses) ?? undefined,
|
||||
companyNumEmployees: toStringOrNull(row.company_num_employees) ?? undefined,
|
||||
companyRevenue: toStringOrNull(row.company_revenue) ?? undefined,
|
||||
companyDescription: toStringOrNull(row.company_description) ?? undefined,
|
||||
skills: toJsonStringOrNull(row.skills) ?? undefined,
|
||||
experienceRange: toJsonStringOrNull(row.experience_range) ?? undefined,
|
||||
companyRating: toNumberOrNull(row.company_rating) ?? undefined,
|
||||
companyReviewsCount: toNumberOrNull(row.company_reviews_count) ?? undefined,
|
||||
vacancyCount: toNumberOrNull(row.vacancy_count) ?? undefined,
|
||||
workFromHomeType: toStringOrNull(row.work_from_home_type) ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
@ -9,6 +9,10 @@ import { fileURLToPath } from 'url';
|
||||
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from './projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Paths - can be overridden via env for Docker
|
||||
@ -28,11 +32,13 @@ export interface PdfResult {
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredSummary: string,
|
||||
jobDescription: string,
|
||||
baseResumePath?: string
|
||||
): Promise<PdfResult> {
|
||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||
@ -54,6 +60,39 @@ export async function generatePdf(
|
||||
} else if (baseResume.basics?.summary) {
|
||||
baseResume.basics.summary = tailoredSummary;
|
||||
}
|
||||
|
||||
// Select projects (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 });
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const selectedSet = new Set([...locked, ...picked]);
|
||||
const projectsSection = (baseResume as any)?.sections?.projects;
|
||||
const projectItems = projectsSection?.items;
|
||||
if (Array.isArray(projectItems)) {
|
||||
for (const item of projectItems) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const id = typeof (item as any).id === 'string' ? (item as any).id : '';
|
||||
if (!id) continue;
|
||||
(item as any).visible = selectedSet.has(id);
|
||||
}
|
||||
projectsSection.visible = selectedSet.size > 0;
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to whatever visibility is in base.json
|
||||
}
|
||||
|
||||
// Write modified resume to temp file
|
||||
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
|
||||
|
||||
168
orchestrator/src/server/services/projectSelection.ts
Normal file
168
orchestrator/src/server/services/projectSelection.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
|
||||
import type { ResumeProjectSelectionItem } from './resumeProjects.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export async function pickProjectIdsForJob(args: {
|
||||
jobDescription: string;
|
||||
eligibleProjects: ResumeProjectSelectionItem[];
|
||||
desiredCount: number;
|
||||
}): Promise<string[]> {
|
||||
const desiredCount = Math.max(0, Math.floor(args.desiredCount));
|
||||
if (desiredCount === 0) return [];
|
||||
|
||||
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||
if (eligibleIds.size === 0) return [];
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
|
||||
const prompt = buildProjectSelectionPrompt({
|
||||
jobDescription: args.jobDescription,
|
||||
projects: args.eligibleProjects,
|
||||
desiredCount,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
if (!content) throw new Error('No content in response');
|
||||
|
||||
const parsed = JSON.parse(content) as any;
|
||||
const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : [];
|
||||
const unique: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of selectedProjectIds) {
|
||||
if (typeof id !== 'string') continue;
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!eligibleIds.has(trimmed)) continue;
|
||||
if (seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
unique.push(trimmed);
|
||||
if (unique.length >= desiredCount) break;
|
||||
}
|
||||
|
||||
if (unique.length === 0) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
return unique;
|
||||
} catch {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
}
|
||||
|
||||
function buildProjectSelectionPrompt(args: {
|
||||
jobDescription: string;
|
||||
projects: ResumeProjectSelectionItem[];
|
||||
desiredCount: number;
|
||||
}): string {
|
||||
const projects = args.projects.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
date: p.date,
|
||||
summary: truncate(p.summaryText, 500),
|
||||
}));
|
||||
|
||||
return `
|
||||
You are selecting which projects to include on a resume for a specific job.
|
||||
|
||||
Rules:
|
||||
- Choose up to ${args.desiredCount} project IDs.
|
||||
- Only choose IDs from the provided list.
|
||||
- Prefer projects that strongly match the job description keywords/tech stack.
|
||||
- Prefer projects that signal impact and real-world engineering.
|
||||
- Do NOT invent projects or skills.
|
||||
|
||||
Job description:
|
||||
${args.jobDescription}
|
||||
|
||||
Candidate projects (pick from these IDs only):
|
||||
${JSON.stringify(projects, null, 2)}
|
||||
|
||||
Respond with JSON only, in this exact shape:
|
||||
{
|
||||
"selectedProjectIds": ["id1", "id2"]
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function fallbackPickProjectIds(
|
||||
jobDescription: string,
|
||||
eligibleProjects: ResumeProjectSelectionItem[],
|
||||
desiredCount: number
|
||||
): string[] {
|
||||
const jd = (jobDescription || '').toLowerCase();
|
||||
|
||||
const signals = [
|
||||
'react',
|
||||
'typescript',
|
||||
'javascript',
|
||||
'node',
|
||||
'next.js',
|
||||
'nextjs',
|
||||
'python',
|
||||
'c++',
|
||||
'c#',
|
||||
'java',
|
||||
'kotlin',
|
||||
'sql',
|
||||
'mongodb',
|
||||
'aws',
|
||||
'docker',
|
||||
'graphql',
|
||||
'php',
|
||||
'unity',
|
||||
'tailwind',
|
||||
];
|
||||
|
||||
const activeSignals = signals.filter((s) => jd.includes(s));
|
||||
|
||||
const scored = eligibleProjects
|
||||
.map((p) => {
|
||||
const text = `${p.name} ${p.description} ${p.summaryText}`.toLowerCase();
|
||||
let score = 0;
|
||||
for (const signal of activeSignals) {
|
||||
if (text.includes(signal)) score += 5;
|
||||
}
|
||||
if (/\b(open source|oss)\b/.test(text)) score += 2;
|
||||
if (/\b(api|backend|frontend|full[- ]?stack)\b/.test(text)) score += 1;
|
||||
return { id: p.id, score };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return scored.slice(0, desiredCount).map((s) => s.id);
|
||||
}
|
||||
|
||||
function truncate(input: string, maxChars: number): string {
|
||||
if (input.length <= maxChars) return input;
|
||||
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
164
orchestrator/src/server/services/resumeProjects.ts
Normal file
164
orchestrator/src/server/services/resumeProjects.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const DEFAULT_RESUME_PROFILE_PATH =
|
||||
process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||
|
||||
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
|
||||
|
||||
export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise<unknown> {
|
||||
const content = await readFile(profilePath, 'utf-8');
|
||||
return JSON.parse(content) as unknown;
|
||||
}
|
||||
|
||||
export function extractProjectsFromProfile(profile: unknown): {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
selectionItems: ResumeProjectSelectionItem[];
|
||||
} {
|
||||
const items = (profile as any)?.sections?.projects?.items;
|
||||
if (!Array.isArray(items)) return { catalog: [], selectionItems: [] };
|
||||
|
||||
const catalog: ResumeProjectCatalogItem[] = [];
|
||||
const selectionItems: ResumeProjectSelectionItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
|
||||
const id = typeof (item as any).id === 'string' ? (item as any).id : '';
|
||||
if (!id) continue;
|
||||
|
||||
const name = typeof (item as any).name === 'string' ? (item as any).name : '';
|
||||
const description = typeof (item as any).description === 'string' ? (item as any).description : '';
|
||||
const date = typeof (item as any).date === 'string' ? (item as any).date : '';
|
||||
const isVisibleInBase = Boolean((item as any).visible);
|
||||
const summary = typeof (item as any).summary === 'string' ? (item as any).summary : '';
|
||||
const summaryText = stripHtml(summary);
|
||||
|
||||
const base: ResumeProjectCatalogItem = { id, name, description, date, isVisibleInBase };
|
||||
catalog.push(base);
|
||||
selectionItems.push({ ...base, summaryText });
|
||||
}
|
||||
|
||||
return { catalog, selectionItems };
|
||||
}
|
||||
|
||||
export function buildDefaultResumeProjectsSettings(
|
||||
catalog: ResumeProjectCatalogItem[]
|
||||
): ResumeProjectsSettings {
|
||||
const lockedProjectIds = catalog.filter((p) => p.isVisibleInBase).map((p) => p.id);
|
||||
const lockedSet = new Set(lockedProjectIds);
|
||||
|
||||
const aiSelectableProjectIds = catalog
|
||||
.map((p) => p.id)
|
||||
.filter((id) => !lockedSet.has(id));
|
||||
|
||||
const total = catalog.length;
|
||||
const preferredMax = Math.max(lockedProjectIds.length, 4);
|
||||
const maxProjects = total === 0 ? 0 : Math.min(total, preferredMax);
|
||||
|
||||
return normalizeResumeProjectsSettings(
|
||||
{ maxProjects, lockedProjectIds, aiSelectableProjectIds },
|
||||
new Set(catalog.map((p) => p.id))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseResumeProjectsSettings(raw: string | null): ResumeProjectsSettings | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as any;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const maxProjects = parsed.maxProjects;
|
||||
const lockedProjectIds = parsed.lockedProjectIds;
|
||||
const aiSelectableProjectIds = parsed.aiSelectableProjectIds;
|
||||
|
||||
if (typeof maxProjects !== 'number') return null;
|
||||
if (!Array.isArray(lockedProjectIds) || !Array.isArray(aiSelectableProjectIds)) return null;
|
||||
if (!lockedProjectIds.every((v: unknown) => typeof v === 'string')) return null;
|
||||
if (!aiSelectableProjectIds.every((v: unknown) => typeof v === 'string')) return null;
|
||||
|
||||
return {
|
||||
maxProjects,
|
||||
lockedProjectIds,
|
||||
aiSelectableProjectIds,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeResumeProjectsSettings(
|
||||
settings: ResumeProjectsSettings,
|
||||
allowedProjectIds?: ReadonlySet<string>
|
||||
): ResumeProjectsSettings {
|
||||
const allowed = allowedProjectIds && allowedProjectIds.size > 0 ? allowedProjectIds : null;
|
||||
|
||||
const lockedProjectIds = uniqueStrings(settings.lockedProjectIds).filter((id) => (allowed ? allowed.has(id) : true));
|
||||
const lockedSet = new Set(lockedProjectIds);
|
||||
|
||||
const aiSelectableProjectIds = uniqueStrings(settings.aiSelectableProjectIds)
|
||||
.filter((id) => (allowed ? allowed.has(id) : true))
|
||||
.filter((id) => !lockedSet.has(id));
|
||||
|
||||
const maxCap = allowed ? allowed.size : Number.POSITIVE_INFINITY;
|
||||
const maxProjectsRaw = Number.isFinite(settings.maxProjects) ? settings.maxProjects : 0;
|
||||
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
|
||||
const minRequired = lockedProjectIds.length;
|
||||
const maxProjects = Math.min(maxCap, Math.max(minRequired, maxProjectsInt));
|
||||
|
||||
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
|
||||
}
|
||||
|
||||
export function resolveResumeProjectsSettings(args: {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
overrideRaw: string | null;
|
||||
}): {
|
||||
profileProjects: ResumeProjectCatalogItem[];
|
||||
defaultResumeProjects: ResumeProjectsSettings;
|
||||
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||
resumeProjects: ResumeProjectsSettings;
|
||||
} {
|
||||
const profileProjects = args.catalog;
|
||||
const allowed = new Set(profileProjects.map((p) => p.id));
|
||||
const defaultResumeProjects = buildDefaultResumeProjectsSettings(profileProjects);
|
||||
const overrideParsed = parseResumeProjectsSettings(args.overrideRaw);
|
||||
const overrideResumeProjects = overrideParsed
|
||||
? normalizeResumeProjectsSettings(overrideParsed, allowed)
|
||||
: null;
|
||||
|
||||
const resumeProjects = overrideResumeProjects
|
||||
? normalizeResumeProjectsSettings(overrideResumeProjects, allowed)
|
||||
: defaultResumeProjects;
|
||||
|
||||
return {
|
||||
profileProjects,
|
||||
defaultResumeProjects,
|
||||
overrideResumeProjects,
|
||||
resumeProjects,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripHtml(input: string): string {
|
||||
const withoutTags = input.replace(/<[^>]*>/g, ' ');
|
||||
return withoutTags.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
if (seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type { ResumeProjectSelectionItem };
|
||||
|
||||
@ -65,7 +65,7 @@ export async function generateSummary(
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
return { success: true, summary: summary.trim() };
|
||||
return { success: true, summary: sanitizeTailoredSummary(summary) };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: message };
|
||||
@ -138,7 +138,7 @@ export async function generateSummaryViaPython(
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
return { success: true, summary: result.trim() };
|
||||
return { success: true, summary: sanitizeTailoredSummary(result) };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: message };
|
||||
@ -151,3 +151,12 @@ export async function generateSummaryViaPython(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@ -172,6 +172,20 @@ export interface PipelineStatusResponse {
|
||||
nextScheduledRun: string | null;
|
||||
}
|
||||
|
||||
export interface ResumeProjectCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
isVisibleInBase: boolean;
|
||||
}
|
||||
|
||||
export interface ResumeProjectsSettings {
|
||||
maxProjects: number;
|
||||
lockedProjectIds: string[];
|
||||
aiSelectableProjectIds: string[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
@ -182,4 +196,8 @@ export interface AppSettings {
|
||||
jobCompleteWebhookUrl: string;
|
||||
defaultJobCompleteWebhookUrl: string;
|
||||
overrideJobCompleteWebhookUrl: string | null;
|
||||
profileProjects: ResumeProjectCatalogItem[];
|
||||
resumeProjects: ResumeProjectsSettings;
|
||||
defaultResumeProjects: ResumeProjectsSettings;
|
||||
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user