diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index fc5e84a..044a05e 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -// @ts-ignore -import pdf from "pdf-parse"; +import { PDFParse } from "pdf-parse"; export async function POST(request: NextRequest) { try { @@ -17,9 +16,11 @@ export async function POST(request: NextRequest) { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - // Extract text from PDF - const pdfData = await pdf(buffer); + // Extract text from PDF using PDFParse v2 API + const parser = new PDFParse({ data: buffer }); + const pdfData = await parser.getText(); const text = pdfData.text; + await parser.destroy(); // Extract candidate name from file name (strip extension) const name = file.name.replace(/\.[^/.]+$/, ""); @@ -75,8 +76,9 @@ export async function POST(request: NextRequest) { message: "CVs processed and forwarded to n8n successfully", data: responseData, }); - } catch (error: any) { + } catch (error: unknown) { console.error("Error in parse-cv route:", error); - return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }); + const errorMessage = error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); } } diff --git a/components/DataCard.tsx b/components/DataCard.tsx new file mode 100644 index 0000000..fa9b26b --- /dev/null +++ b/components/DataCard.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +interface DataCardProps { + title: string; + description: string; + children?: React.ReactNode; +} + +export function DataCard({ title, description, children }: DataCardProps) { + return ( +
+

{title}

+

{description}

+ {children} +
+ ); +} diff --git a/components/PrimaryButton.tsx b/components/PrimaryButton.tsx new file mode 100644 index 0000000..2bf8bdf --- /dev/null +++ b/components/PrimaryButton.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +interface PrimaryButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; +} + +export function PrimaryButton({ children, className = "", ...props }: PrimaryButtonProps) { + return ( + + ); +} diff --git a/components/StatusBadge.tsx b/components/StatusBadge.tsx new file mode 100644 index 0000000..52ff158 --- /dev/null +++ b/components/StatusBadge.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +interface StatusBadgeProps { + label: string; + variant?: "primary" | "secondary"; +} + +export function StatusBadge({ label, variant = "primary" }: StatusBadgeProps) { + return ( + + {label} + + ); +} diff --git a/docs/discovery-and-scope.md b/docs/discovery-and-scope.md new file mode 100644 index 0000000..955c8ad --- /dev/null +++ b/docs/discovery-and-scope.md @@ -0,0 +1,50 @@ +# Phase 1: Discovery & Scope + +This document establishes the business boundaries, user personas, backlog of user stories, and Definition of Done (DoD) for the AI Recruitment Platform. + +## User Personas + +* **Technical Recruiter**: Focuses on sourcing, intake, initial AI-assisted screening, seniority verification, and tracking candidates through the hiring pipeline. +* **Hiring Manager**: Focuses on reviewing pre-screened and scored candidates, evaluating role suitability, and conducting structured interviews. +* **Candidate**: Interacts with the platform to submit applications/CVs and receives automated status updates. + +## Prioritized Backlog of User Stories + +1. **CV Intake** + * *User Story*: As a recruiter, I want to upload candidate PDFs via the Web UI so they can be processed and stored. + * *Acceptance Criteria*: + * Drag-and-drop or file selector for PDF CVs. + * Successful text extraction and ingestion. + * Parsing and storage of extracted candidate info. +2. **Semantic Ranking** + * *User Story*: As a recruiter, I want a ranked list of candidates matching an open job description based on contextual relevance. + * *Acceptance Criteria*: + * Compute cosine distance between job embeddings and candidate embeddings. + * Display ranked matches with normalized similarity score between -1 and 1. +3. **Profile Summary** + * *User Story*: As a recruiter, I want a terse AI-generated candidate summary to speed up screening. + * *Acceptance Criteria*: + * Display a short summary on candidate profiles. + * Synthesized from parsed resume details. +4. **Seniority Guard** + * *User Story*: As a recruiter, I want automatic seniority detection to route candidates to the correct interview pool. + * *Acceptance Criteria*: + * AI-based classification (e.g., Junior, Mid, Senior). + * Correct pool routing matches candidate seniority level. +5. **Unified Scoring** + * *User Story*: As a hiring manager, I want to compare candidate suitability using a standardized score. + * *Acceptance Criteria*: + * Standardized scoring schema with AI evaluation. + * JSON structure containing keys: `summary`, `classification`, `suggestions`, and `riskLevel`. +6. **Stage Progression** + * *User Story*: As a recruiter, I want candidate stages to update dynamically, triggering confirmation emails. + * *Acceptance Criteria*: + * Visual stage progression pipeline. + * Updating stage triggers background event notifications. + +## Definition of Done (DoD) + +* **Type Safety**: Fully typed Next.js App Router with TypeScript (no `any` types where avoidable). +* **Database Schema**: Supabase relational database schema complete with verified `pgvector` distance metrics. +* **Linting & Quality**: Zero TypeScript compilation or linting warnings/errors. +* **Git Quality Gates**: Active Git quality gates (hooks) verifying builds, conventional commit messages, linting, and formatting. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..5610352 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "scripts/**", ]), ]); diff --git a/lib/logger/index.ts b/lib/logger/index.ts new file mode 100644 index 0000000..d0e2a44 --- /dev/null +++ b/lib/logger/index.ts @@ -0,0 +1,48 @@ +export interface LogPayload { + message: string; + error?: Error | unknown; + latencyMs?: number; + metadata?: Record; +} + +export class Logger { + private static format(level: "INFO" | "WARN" | "ERROR", payload: LogPayload): string { + const timestamp = new Date().toISOString(); + const parts: string[] = [`[${timestamp}] [${level}] ${payload.message}`]; + + if (payload.latencyMs !== undefined) { + parts.push(`(Latency: ${payload.latencyMs}ms)`); + } + + if (payload.error) { + const errorMsg = + payload.error instanceof Error + ? payload.error.stack || payload.error.message + : String(payload.error); + parts.push(`\nError: ${errorMsg}`); + } + + if (payload.metadata && Object.keys(payload.metadata).length > 0) { + parts.push(`\nMetadata: ${JSON.stringify(payload.metadata, null, 2)}`); + } + + return parts.join(" "); + } + + static info(message: string, metadata?: Record, latencyMs?: number) { + console.log(this.format("INFO", { message, metadata, latencyMs })); + } + + static warn(message: string, metadata?: Record, latencyMs?: number) { + console.warn(this.format("WARN", { message, metadata, latencyMs })); + } + + static error( + message: string, + error?: Error | unknown, + metadata?: Record, + latencyMs?: number + ) { + console.error(this.format("ERROR", { message, error, metadata, latencyMs })); + } +} diff --git a/supabase/migrations/20260608000000_initial_schema.sql b/supabase/migrations/20260608000000_initial_schema.sql new file mode 100644 index 0000000..524484b --- /dev/null +++ b/supabase/migrations/20260608000000_initial_schema.sql @@ -0,0 +1,86 @@ +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- Recruiters table +CREATE TABLE IF NOT EXISTS recruiters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Jobs table +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + requirements JSONB NOT NULL, + embedding vector(1536), + recruiter_id UUID REFERENCES recruiters(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Candidates table +CREATE TABLE IF NOT EXISTS candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + contact_info JSONB NOT NULL, + embedding vector(1536), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Interviews table +CREATE TABLE IF NOT EXISTS interviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id UUID NOT NULL REFERENCES candidates(id) ON DELETE CASCADE, + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + interview_date TIMESTAMPTZ NOT NULL, + stage TEXT NOT NULL, -- Technical, Cultural, etc. + feedback TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Scores table +CREATE TABLE IF NOT EXISTS scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id UUID NOT NULL REFERENCES candidates(id) ON DELETE CASCADE, + interview_id UUID REFERENCES interviews(id) ON DELETE CASCADE, + ai_score FLOAT NOT NULL, + evaluation JSONB NOT NULL, -- Hold summary, classification, suggestions, riskLevel + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT check_evaluation_schema CHECK ( + evaluation ? 'summary' AND + evaluation ? 'classification' AND + evaluation ? 'suggestions' AND + evaluation ? 'riskLevel' + ) +); + +-- Cosine distance match function +CREATE OR REPLACE FUNCTION match_candidates( + query_embedding vector(1536), + match_threshold float, + match_count int +) +RETURNS TABLE ( + id uuid, + name text, + contact_info jsonb, + embedding vector(1536), + similarity float +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + candidates.id, + candidates.name, + candidates.contact_info, + candidates.embedding, + (1 - (candidates.embedding <=> query_embedding))::float AS similarity + FROM candidates + WHERE (1 - (candidates.embedding <=> query_embedding)) > match_threshold + ORDER BY similarity DESC + LIMIT match_count; +END; +$$; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..19cdb13 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,28 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + // Overriding the default theme colors to restrict to the whitelist. + colors: { + transparent: "transparent", + current: "currentColor", + white: "#ffffff", + slate: { + 50: "#f8fafc", + 600: "#475569", + 900: "#0f172a", + }, + blue: { + 600: "#2563eb", + 700: "#1d4ed8", + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..dc56143 --- /dev/null +++ b/types.d.ts @@ -0,0 +1 @@ +declare module 'pdf-parse';