feat(foundations): init schema frontend components and quality gates
This commit is contained in:
parent
84a8cf52d7
commit
dd7e0ff198
10 changed files with 275 additions and 6 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
// @ts-ignore
|
import { PDFParse } from "pdf-parse";
|
||||||
import pdf from "pdf-parse";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -17,9 +16,11 @@ export async function POST(request: NextRequest) {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
// Extract text from PDF
|
// Extract text from PDF using PDFParse v2 API
|
||||||
const pdfData = await pdf(buffer);
|
const parser = new PDFParse({ data: buffer });
|
||||||
|
const pdfData = await parser.getText();
|
||||||
const text = pdfData.text;
|
const text = pdfData.text;
|
||||||
|
await parser.destroy();
|
||||||
|
|
||||||
// Extract candidate name from file name (strip extension)
|
// Extract candidate name from file name (strip extension)
|
||||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
@ -75,8 +76,9 @@ export async function POST(request: NextRequest) {
|
||||||
message: "CVs processed and forwarded to n8n successfully",
|
message: "CVs processed and forwarded to n8n successfully",
|
||||||
data: responseData,
|
data: responseData,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error("Error in parse-cv route:", error);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
components/DataCard.tsx
Normal file
17
components/DataCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-50">
|
||||||
|
<h3 className="text-lg font-bold text-slate-900 mb-1">{title}</h3>
|
||||||
|
<p className="text-slate-600 text-sm mb-4">{description}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/PrimaryButton.tsx
Normal file
16
components/PrimaryButton.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface PrimaryButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({ children, className = "", ...props }: PrimaryButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm transition-colors text-sm ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/StatusBadge.tsx
Normal file
20
components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
label: string;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ label, variant = "primary" }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
variant === "primary"
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-slate-50 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
docs/discovery-and-scope.md
Normal file
50
docs/discovery-and-scope.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"scripts/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
48
lib/logger/index.ts
Normal file
48
lib/logger/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
export interface LogPayload {
|
||||||
|
message: string;
|
||||||
|
error?: Error | unknown;
|
||||||
|
latencyMs?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>, latencyMs?: number) {
|
||||||
|
console.log(this.format("INFO", { message, metadata, latencyMs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
static warn(message: string, metadata?: Record<string, unknown>, latencyMs?: number) {
|
||||||
|
console.warn(this.format("WARN", { message, metadata, latencyMs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(
|
||||||
|
message: string,
|
||||||
|
error?: Error | unknown,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
latencyMs?: number
|
||||||
|
) {
|
||||||
|
console.error(this.format("ERROR", { message, error, metadata, latencyMs }));
|
||||||
|
}
|
||||||
|
}
|
||||||
86
supabase/migrations/20260608000000_initial_schema.sql
Normal file
86
supabase/migrations/20260608000000_initial_schema.sql
Normal file
|
|
@ -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;
|
||||||
|
$$;
|
||||||
28
tailwind.config.ts
Normal file
28
tailwind.config.ts
Normal file
|
|
@ -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;
|
||||||
1
types.d.ts
vendored
Normal file
1
types.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
declare module 'pdf-parse';
|
||||||
Loading…
Reference in a new issue