manual import initial comit

This commit is contained in:
DaKheera47 2026-01-19 18:39:14 +00:00
parent 3b2aa70e8c
commit 4f4f00e42c
10 changed files with 779 additions and 5 deletions

View File

@ -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');

View File

@ -73,6 +73,7 @@ const sourceLabel: Record<Job["source"], string> = {
indeed: "Indeed",
linkedin: "LinkedIn",
ukvisajobs: "UK Visa Jobs",
manual: "Manual",
};
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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"];

View 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>
);
};

View File

@ -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';

View File

@ -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">

View File

@ -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 });
}
});

View File

@ -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'),

View 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;
}

View File

@ -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;