manual import initial comit
This commit is contained in:
parent
3b2aa70e8c
commit
4f4f00e42c
@ -15,6 +15,8 @@ import type {
|
||||
UkVisaJobsSearchResponse,
|
||||
UkVisaJobsImportResponse,
|
||||
CreateJobInput,
|
||||
ManualJobDraft,
|
||||
ManualJobInferenceResponse,
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
@ -138,6 +140,25 @@ export async function importUkVisaJobs(input: {
|
||||
});
|
||||
}
|
||||
|
||||
// Manual Job Import API
|
||||
export async function inferManualJob(input: {
|
||||
jobDescription: string;
|
||||
}): Promise<ManualJobInferenceResponse> {
|
||||
return fetchApi<ManualJobInferenceResponse>('/manual-jobs/infer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importManualJob(input: {
|
||||
job: ManualJobDraft;
|
||||
}): Promise<Job> {
|
||||
return fetchApi<Job>('/manual-jobs/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
// Settings & Profile API
|
||||
export async function getSettings(): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings');
|
||||
|
||||
@ -73,6 +73,7 @@ const sourceLabel: Record<Job["source"], string> = {
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -60,6 +60,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
424
orchestrator/src/client/components/ManualImportSheet.tsx
Normal file
424
orchestrator/src/client/components/ManualImportSheet.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Manual job import flow (paste JD -> infer -> review -> import).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import type { ManualJobDraft } from "../../shared/types";
|
||||
|
||||
type ManualImportStep = "paste" | "loading" | "review";
|
||||
|
||||
type ManualJobDraftState = {
|
||||
title: string;
|
||||
employer: string;
|
||||
jobUrl: string;
|
||||
applicationLink: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobDescription: string;
|
||||
jobType: string;
|
||||
jobLevel: string;
|
||||
jobFunction: string;
|
||||
disciplines: string;
|
||||
degreeRequired: string;
|
||||
starting: string;
|
||||
};
|
||||
|
||||
const emptyDraft: ManualJobDraftState = {
|
||||
title: "",
|
||||
employer: "",
|
||||
jobUrl: "",
|
||||
applicationLink: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
deadline: "",
|
||||
jobDescription: "",
|
||||
jobType: "",
|
||||
jobLevel: "",
|
||||
jobFunction: "",
|
||||
disciplines: "",
|
||||
degreeRequired: "",
|
||||
starting: "",
|
||||
};
|
||||
|
||||
const normalizeDraft = (draft?: ManualJobDraft | null, jd?: string): ManualJobDraftState => ({
|
||||
...emptyDraft,
|
||||
title: draft?.title ?? "",
|
||||
employer: draft?.employer ?? "",
|
||||
jobUrl: draft?.jobUrl ?? "",
|
||||
applicationLink: draft?.applicationLink ?? "",
|
||||
location: draft?.location ?? "",
|
||||
salary: draft?.salary ?? "",
|
||||
deadline: draft?.deadline ?? "",
|
||||
jobDescription: jd ?? draft?.jobDescription ?? "",
|
||||
jobType: draft?.jobType ?? "",
|
||||
jobLevel: draft?.jobLevel ?? "",
|
||||
jobFunction: draft?.jobFunction ?? "",
|
||||
disciplines: draft?.disciplines ?? "",
|
||||
degreeRequired: draft?.degreeRequired ?? "",
|
||||
starting: draft?.starting ?? "",
|
||||
});
|
||||
|
||||
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
|
||||
const clean = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: clean(draft.title),
|
||||
employer: clean(draft.employer),
|
||||
jobUrl: clean(draft.jobUrl),
|
||||
applicationLink: clean(draft.applicationLink),
|
||||
location: clean(draft.location),
|
||||
salary: clean(draft.salary),
|
||||
deadline: clean(draft.deadline),
|
||||
jobDescription: clean(draft.jobDescription),
|
||||
jobType: clean(draft.jobType),
|
||||
jobLevel: clean(draft.jobLevel),
|
||||
jobFunction: clean(draft.jobFunction),
|
||||
disciplines: clean(draft.disciplines),
|
||||
degreeRequired: clean(draft.degreeRequired),
|
||||
starting: clean(draft.starting),
|
||||
};
|
||||
};
|
||||
|
||||
interface ManualImportSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported: (jobId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ManualImportStep>("paste");
|
||||
const [rawDescription, setRawDescription] = useState("");
|
||||
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStep("paste");
|
||||
setRawDescription("");
|
||||
setDraft(emptyDraft);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||
|
||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||
const canImport = useMemo(() => {
|
||||
if (step !== "review") return false;
|
||||
return (
|
||||
draft.title.trim().length > 0 &&
|
||||
draft.employer.trim().length > 0 &&
|
||||
draft.jobDescription.trim().length > 0
|
||||
);
|
||||
}, [draft, step]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!rawDescription.trim()) {
|
||||
setError("Paste a job description to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setStep("loading");
|
||||
const response = await api.inferManualJob({ jobDescription: rawDescription });
|
||||
setDraft(normalizeDraft(response.job, rawDescription.trim()));
|
||||
setWarning(response.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to analyze job description";
|
||||
setError(message);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!canImport) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
const payload = toPayload(draft);
|
||||
const created = await api.importManualJob({ job: payload });
|
||||
toast.success("Job imported", {
|
||||
description: "The job is now in the discovered column.",
|
||||
});
|
||||
await onImported(created.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to import job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-xl overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
Manual Import
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Paste a job description, review the AI draft, then import the role.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Step {stepIndex + 1} of 3</span>
|
||||
<span>{stepLabel}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted/40">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/60 transition-all"
|
||||
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1 overflow-y-auto pr-1">
|
||||
{step === "paste" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</label>
|
||||
<Textarea
|
||||
value={rawDescription}
|
||||
onChange={(event) => setRawDescription(event.target.value)}
|
||||
placeholder="Paste the full job description here..."
|
||||
className="min-h-[220px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!canAnalyze}
|
||||
className="w-full h-10 gap-2"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Analyze JD
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "loading" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-semibold">Analyzing job description</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Extracting title, company, location, and other details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4 pb-4">
|
||||
{warning && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep("paste")}
|
||||
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Edit JD
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Required: title, employer, description
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
||||
placeholder="e.g. Junior Backend Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Employer *</label>
|
||||
<Input
|
||||
value={draft.employer}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, employer: event.target.value }))}
|
||||
placeholder="e.g. Acme Labs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Location</label>
|
||||
<Input
|
||||
value={draft.location}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, location: event.target.value }))}
|
||||
placeholder="e.g. London, UK"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Salary</label>
|
||||
<Input
|
||||
value={draft.salary}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, salary: event.target.value }))}
|
||||
placeholder="e.g. GBP 45k-55k"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Deadline</label>
|
||||
<Input
|
||||
value={draft.deadline}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, deadline: event.target.value }))}
|
||||
placeholder="e.g. 30 Sep 2025"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job type</label>
|
||||
<Input
|
||||
value={draft.jobType}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobType: event.target.value }))}
|
||||
placeholder="e.g. Full-time"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job level</label>
|
||||
<Input
|
||||
value={draft.jobLevel}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobLevel: event.target.value }))}
|
||||
placeholder="e.g. Graduate"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job function</label>
|
||||
<Input
|
||||
value={draft.jobFunction}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobFunction: event.target.value }))}
|
||||
placeholder="e.g. Software Engineering"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Disciplines</label>
|
||||
<Input
|
||||
value={draft.disciplines}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, disciplines: event.target.value }))}
|
||||
placeholder="e.g. Computer Science"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Degree required</label>
|
||||
<Input
|
||||
value={draft.degreeRequired}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, degreeRequired: event.target.value }))}
|
||||
placeholder="e.g. BSc or MSc"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Starting</label>
|
||||
<Input
|
||||
value={draft.starting}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, starting: event.target.value }))}
|
||||
placeholder="e.g. Summer 2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Job URL</label>
|
||||
<Input
|
||||
value={draft.jobUrl}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobUrl: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Application link</label>
|
||||
<Input
|
||||
value={draft.applicationLink}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, applicationLink: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Job description *
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.jobDescription}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobDescription: event.target.value }))}
|
||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
placeholder="Paste the job description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Separator />
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!canImport || isImporting}
|
||||
className={cn("w-full h-10 gap-2", !canImport && "opacity-70")}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
{isImporting ? "Importing..." : "Import job"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -7,4 +7,5 @@ export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
export { DiscoveredPanel } from './DiscoveredPanel';
|
||||
export { ReadyPanel } from './ReadyPanel';
|
||||
export { ManualImportSheet } from './ManualImportSheet';
|
||||
export * from './layout';
|
||||
|
||||
@ -60,7 +60,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import { PipelineProgress, DiscoveredPanel } from "../components";
|
||||
import { PipelineProgress, DiscoveredPanel, ManualImportSheet } from "../components";
|
||||
import { ReadyPanel } from "../components/ReadyPanel";
|
||||
import * as api from "../api";
|
||||
import { TailoringEditor } from "../components/TailoringEditor";
|
||||
@ -74,6 +74,7 @@ const sourceLabel: Record<JobSource, string> = {
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
@ -324,6 +325,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
];
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
|
||||
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@ -384,6 +386,16 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleManualImported = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActiveTab("discovered");
|
||||
setSourceFilter("all");
|
||||
await loadJobs();
|
||||
setSelectedJobId(jobId);
|
||||
},
|
||||
[loadJobs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
@ -1123,6 +1135,15 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsManualImportOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Manual import</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -1408,6 +1429,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ManualImportSheet
|
||||
open={isManualImportOpen}
|
||||
onOpenChange={setIsManualImportOpen}
|
||||
onImported={handleManualImported}
|
||||
/>
|
||||
|
||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import * as jobsRepo from '../repositories/jobs.js';
|
||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||
@ -10,6 +11,7 @@ import * as settingsRepo from '../repositories/settings.js';
|
||||
import { runPipeline, processJob, summarizeJob, generateFinalPdf, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||
import { createNotionEntry } from '../services/notion.js';
|
||||
import { fetchUkVisaJobsPage } from '../services/ukvisajobs.js';
|
||||
import { inferManualJobDetails } from '../services/manualJob.js';
|
||||
import { clearDatabase } from '../db/clear.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
@ -18,7 +20,18 @@ import {
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../services/resumeProjects.js';
|
||||
import * as visaSponsors from '../services/visa-sponsors/index.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse, VisaSponsorSearchResponse, VisaSponsorStatusResponse } from '../../shared/types.js';
|
||||
import type {
|
||||
Job,
|
||||
JobStatus,
|
||||
ApiResponse,
|
||||
JobsListResponse,
|
||||
PipelineStatusResponse,
|
||||
UkVisaJobsSearchResponse,
|
||||
UkVisaJobsImportResponse,
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
ManualJobInferenceResponse,
|
||||
} from '../../shared/types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
let isUkVisaJobsSearchRunning = false;
|
||||
@ -735,6 +748,106 @@ apiRouter.post('/pipeline/run', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Manual Job Import API
|
||||
// ============================================================================
|
||||
|
||||
const manualJobInferenceSchema = z.object({
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
});
|
||||
|
||||
const manualJobImportSchema = z.object({
|
||||
job: z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
employer: z.string().trim().min(1).max(500),
|
||||
jobUrl: z.string().trim().max(2000).optional(),
|
||||
applicationLink: z.string().trim().max(2000).optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
salary: z.string().trim().max(200).optional(),
|
||||
deadline: z.string().trim().max(100).optional(),
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
jobType: z.string().trim().max(200).optional(),
|
||||
jobLevel: z.string().trim().max(200).optional(),
|
||||
jobFunction: z.string().trim().max(200).optional(),
|
||||
disciplines: z.string().trim().max(200).optional(),
|
||||
degreeRequired: z.string().trim().max(200).optional(),
|
||||
starting: z.string().trim().max(200).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const cleanOptional = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/infer - Infer job details from a pasted description
|
||||
*/
|
||||
apiRouter.post('/manual-jobs/infer', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobInferenceSchema.parse(req.body ?? {});
|
||||
const result = await inferManualJobDetails(input.jobDescription);
|
||||
|
||||
const response: ApiResponse<ManualJobInferenceResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
job: result.job,
|
||||
warning: result.warning ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/import - Import a manually curated job into the DB
|
||||
*/
|
||||
apiRouter.post('/manual-jobs/import', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobImportSchema.parse(req.body ?? {});
|
||||
const job = input.job;
|
||||
|
||||
const jobUrl =
|
||||
cleanOptional(job.jobUrl) ||
|
||||
cleanOptional(job.applicationLink) ||
|
||||
`manual://${randomUUID()}`;
|
||||
|
||||
const createdJob = await jobsRepo.createJob({
|
||||
source: 'manual',
|
||||
title: job.title.trim(),
|
||||
employer: job.employer.trim(),
|
||||
jobUrl,
|
||||
applicationLink: cleanOptional(job.applicationLink) ?? undefined,
|
||||
location: cleanOptional(job.location) ?? undefined,
|
||||
salary: cleanOptional(job.salary) ?? undefined,
|
||||
deadline: cleanOptional(job.deadline) ?? undefined,
|
||||
jobDescription: job.jobDescription.trim(),
|
||||
jobType: cleanOptional(job.jobType) ?? undefined,
|
||||
jobLevel: cleanOptional(job.jobLevel) ?? undefined,
|
||||
jobFunction: cleanOptional(job.jobFunction) ?? undefined,
|
||||
disciplines: cleanOptional(job.disciplines) ?? undefined,
|
||||
degreeRequired: cleanOptional(job.degreeRequired) ?? undefined,
|
||||
starting: cleanOptional(job.starting) ?? undefined,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: createdJob });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UK Visa Jobs API
|
||||
// ============================================================================
|
||||
@ -1054,4 +1167,3 @@ apiRouter.post('/visa-sponsors/update', async (req: Request, res: Response) => {
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export const jobs = sqliteTable('jobs', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
// From crawler
|
||||
source: text('source', { enum: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'] }).notNull().default('gradcracker'),
|
||||
source: text('source', { enum: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs', 'manual'] }).notNull().default('gradcracker'),
|
||||
sourceJobId: text('source_job_id'),
|
||||
jobUrlDirect: text('job_url_direct'),
|
||||
datePosted: text('date_posted'),
|
||||
|
||||
164
orchestrator/src/server/services/manualJob.ts
Normal file
164
orchestrator/src/server/services/manualJob.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Service for inferring job details from a pasted job description.
|
||||
*/
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import type { ManualJobDraft } from '../../shared/types.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface ManualJobInferenceResult {
|
||||
job: ManualJobDraft;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
job: {},
|
||||
warning: 'OPENROUTER_API_KEY not set. Fill details manually.',
|
||||
};
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const prompt = buildInferencePrompt(jobDescription);
|
||||
|
||||
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 = parseJsonFromContent(content);
|
||||
return { job: normalizeDraft(parsed) };
|
||||
} catch (error) {
|
||||
console.warn('Manual job inference failed:', error);
|
||||
return {
|
||||
job: {},
|
||||
warning: 'AI inference failed. Fill details manually.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildInferencePrompt(jd: string): string {
|
||||
return `
|
||||
You are extracting structured data from a job description.
|
||||
Return JSON only with the keys listed below. Use empty string if unknown.
|
||||
Do not guess or invent data.
|
||||
|
||||
Keys:
|
||||
- title
|
||||
- employer
|
||||
- location
|
||||
- salary
|
||||
- deadline
|
||||
- jobUrl (the listing URL, if present)
|
||||
- applicationLink (the apply URL, if present)
|
||||
- jobType
|
||||
- jobLevel
|
||||
- jobFunction
|
||||
- disciplines
|
||||
- degreeRequired
|
||||
- starting
|
||||
|
||||
JOB DESCRIPTION:
|
||||
${jd.slice(0, 8000)}${jd.length > 8000 ? '... (truncated)' : ''}
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
{
|
||||
"title": "",
|
||||
"employer": "",
|
||||
"location": "",
|
||||
"salary": "",
|
||||
"deadline": "",
|
||||
"jobUrl": "",
|
||||
"applicationLink": "",
|
||||
"jobType": "",
|
||||
"jobLevel": "",
|
||||
"jobFunction": "",
|
||||
"disciplines": "",
|
||||
"degreeRequired": "",
|
||||
"starting": ""
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function parseJsonFromContent(content: string): Record<string, unknown> {
|
||||
const trimmed = content.trim();
|
||||
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFences);
|
||||
} catch {
|
||||
const firstBrace = withoutFences.indexOf('{');
|
||||
const lastBrace = withoutFences.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sliced = withoutFences.slice(firstBrace, lastBrace + 1);
|
||||
return JSON.parse(sliced);
|
||||
}
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
|
||||
const fields: Array<keyof ManualJobDraft> = [
|
||||
'title',
|
||||
'employer',
|
||||
'location',
|
||||
'salary',
|
||||
'deadline',
|
||||
'jobUrl',
|
||||
'applicationLink',
|
||||
'jobType',
|
||||
'jobLevel',
|
||||
'jobFunction',
|
||||
'disciplines',
|
||||
'degreeRequired',
|
||||
'starting',
|
||||
'jobDescription',
|
||||
];
|
||||
|
||||
const out: ManualJobDraft = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = toCleanString(parsed[field]);
|
||||
if (value) out[field] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCleanString(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@ -14,7 +14,8 @@ export type JobSource =
|
||||
| 'gradcracker'
|
||||
| 'indeed'
|
||||
| 'linkedin'
|
||||
| 'ukvisajobs';
|
||||
| 'ukvisajobs'
|
||||
| 'manual';
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@ -129,6 +130,28 @@ export interface CreateJobInput {
|
||||
workFromHomeType?: string;
|
||||
}
|
||||
|
||||
export interface ManualJobDraft {
|
||||
title?: string;
|
||||
employer?: string;
|
||||
jobUrl?: string;
|
||||
applicationLink?: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
deadline?: string;
|
||||
jobDescription?: string;
|
||||
jobType?: string;
|
||||
jobLevel?: string;
|
||||
jobFunction?: string;
|
||||
disciplines?: string;
|
||||
degreeRequired?: string;
|
||||
starting?: string;
|
||||
}
|
||||
|
||||
export interface ManualJobInferenceResponse {
|
||||
job: ManualJobDraft;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateJobInput {
|
||||
status?: JobStatus;
|
||||
jobDescription?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user