Compare commits
8 commits
88815f6895
...
f429277fc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f429277fc3 | ||
|
|
c6024efcef | ||
|
|
30546bcb24 | ||
|
|
25fd894751 | ||
|
|
8cd54908d0 | ||
|
|
a264c08922 | ||
|
|
5f87b58baa | ||
|
|
628faa7e24 |
14 changed files with 1132 additions and 284 deletions
121
README.md
121
README.md
|
|
@ -1,26 +1,107 @@
|
|||
# AI Recruitment Platform (ATS)
|
||||
# Semillero AI Recruitment Platform (ATS)
|
||||
|
||||
## Project Description
|
||||
A modern ATS platform designed to parse PDF CVs using multimodal AI, rank candidates against job vacancies using semantic vector search, and orchestrate automated recruitment stages via an external n8n hub.
|
||||
A modern Applicant Tracking System (ATS) built with Next.js App Router, Tailwind CSS, Supabase, pgvector, Google Gemini, and n8n automation. It parses PDF resumes, ranks candidates with semantic vector similarity search, and manages candidate pipelines with real-time comments, pinning, and n8n-powered next-step AI suggestions.
|
||||
|
||||
## Core User Stories
|
||||
- **Recruiter - CV Upload:** As a recruiter, I want to upload CVs in PDF format so they can be evaluated automatically.
|
||||
- **Recruiter - Vacancy Ranking:** As a recruiter, I want a ranking of candidates per job vacancy.
|
||||
- **Recruiter - Seniority Detection:** As a recruiter, I want the system to detect seniority to adjust interviews.
|
||||
- **Recruiter - Profile Summary:** As a recruiter, I want a concise AI summary of the profile for quick review.
|
||||
- **Hiring Manager - Comparative Scoring:** As a hiring manager, I want a comparative score between candidates.
|
||||
- **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically.
|
||||
- **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change.
|
||||
- **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy.
|
||||
---
|
||||
|
||||
## Setup & Prerequisites
|
||||
## 🔒 Security & Access Control
|
||||
|
||||
### 1. Google AI Studio Account (Mandatory)
|
||||
Vector embeddings matching and search operations require a direct call to the Google Gemini Embeddings API (`models/gemini-embedding-001`).
|
||||
* **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/).
|
||||
* **Usage**: The embedding model is free for up to 1,500 requests per day (15 requests per minute), which covers standard development and testing requirements.
|
||||
* **Configuration**: Add your key to the `.env` file at the root of the project:
|
||||
```env
|
||||
GEMINI_API_KEY=your_google_ai_studio_api_key_here
|
||||
The application is protected by a global secure access gate at startup.
|
||||
|
||||
* **Security Lock Screen**: Any access to the platform redirects to a bilingual lock screen requiring a password.
|
||||
* **Encrypted Transmission**: The password is submitted encrypted over HTTPS to a secure server-side endpoint `/api/auth`.
|
||||
* **Environment Configuration**: Store your password in the `.env` file under `APP_PASSWORD`.
|
||||
* **Default Fallback**: If no `APP_PASSWORD` env variable is set, the system defaults to:
|
||||
```
|
||||
Semillero2026!
|
||||
```
|
||||
* **Auto Logoff**: Sessions are tracked via a secure timestamp. Users are automatically logged off and returned to the lock screen after **24 hours (1 day)** of inactivity.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Setup & Environment Configuration
|
||||
|
||||
Create a `.env` file at the root of the project with the following variables:
|
||||
|
||||
```env
|
||||
# Database Credentials
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
SUPABASE_SECRET_KEY=your_supabase_service_role_key
|
||||
|
||||
# Security Access Lock
|
||||
APP_PASSWORD=Semillero2026!
|
||||
|
||||
# LLM Core API Key
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
# n8n Automation Engine Settings
|
||||
N8N_HOST=https://n8n.yourdomain.com
|
||||
N8N_API_KEY=your_n8n_api_key
|
||||
NEXT_PUBLIC_N8N_WEBHOOK_URL=https://n8n.yourdomain.com/webhook/evaluate-candidate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & Running
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Run Development Server
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Production Build & Verification
|
||||
```bash
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 4. Deploy n8n Workflow
|
||||
To deploy or update the recruitment workflows inside your n8n workspace, run:
|
||||
```bash
|
||||
npx ts-node -O '{"module": "commonjs"}' scripts/deploy-n8n-v2.ts --fallback --fallback-provider=gemini
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Interactive Demo Script (Presentation Walkthrough)
|
||||
|
||||
Follow these steps to demonstrate the end-to-end capabilities during your presentation:
|
||||
|
||||
### Step 1: Secure Login & Startup
|
||||
1. Open the application. You will be greeted by the **Secure Access Lock Screen**.
|
||||
2. Select your preferred language (English or Spanish) and toggle between **Light/Dark Mode** using the top-right controls.
|
||||
3. Enter the password `Semillero2026!` and click **Unlock**.
|
||||
|
||||
### Step 2: Define a Job Vacancy
|
||||
1. Navigate to the **Vacancies** (Vacantes) tab.
|
||||
2. Click **Create Vacancy**. Enter a Title (e.g. `Senior Frontend Engineer`) and requirements.
|
||||
3. Observe the newly created card appear in the vacancy sidebar.
|
||||
|
||||
### Step 3: Ingest Candidate CVs (RAG parsing)
|
||||
1. Select the newly created vacancy.
|
||||
2. Drag and drop or upload a candidate CV in PDF format.
|
||||
3. The system parses the PDF, generates a text vector embedding, inserts the candidate record, and automatically creates an initial **Screening** interview.
|
||||
|
||||
### Step 4: Run AI Suitability Match
|
||||
1. Click **Run AI Evaluation** on the candidate.
|
||||
2. Observe the suitabilty score (0-100), risk level (Low, Medium, High), classification, and a concise 3-sentence summary generated by Gemini.
|
||||
3. Check the A-Z list of candidates in the **Candidates** (Candidatos) tab to see the details, linked vacancies, and duplicate file warnings if you re-upload the same PDF.
|
||||
|
||||
### Step 5: Pipeline Interview Management
|
||||
1. Go to the **Interviews** (Entrevistas) tab.
|
||||
2. Observe the **Open Positions** sidebar showing positions and candidate counts.
|
||||
3. Click on a candidate. Watch the **Open Positions** sidebar slide out of view with a smooth CSS transition, and the Candidate List + Detail Pane expand to fill the open space.
|
||||
4. Toggle the **Pin** button next to the candidate's name to move them to the top of the list.
|
||||
|
||||
### Step 6: Interactive Timestamps & AI next steps
|
||||
1. Click **Get AI Suggestion** inside the candidate's interview detail pane.
|
||||
2. An n8n webhook will query Gemini and append a concise markdown list of recommended next steps as a comment in the timeline.
|
||||
3. Try clicking on any comment card. Observe it collapse/uncollapse with a chevron icon transition.
|
||||
4. Modify the interview **Stage** dropdown. The stage updates instantly in the database with a timestamp log in the comment timeline.
|
||||
5. Click **Back to All Interviews** in the top navigation. Watch the **Open Positions** sidebar animate back into view.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||
import { generateEmbedding } from "@/lib/embeddings";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { extractCandidateProfile } from "@/lib/gemini";
|
||||
|
||||
// Polyfill missing DOM APIs in Next.js Serverless / Node environment for pdfjs-dist
|
||||
if (typeof global !== "undefined") {
|
||||
const g = global as Record<string, unknown>;
|
||||
if (!g["DOMMatrix"]) g["DOMMatrix"] = class DOMMatrix {};
|
||||
if (!g["ImageData"]) g["ImageData"] = class ImageData {};
|
||||
if (!g["Path2D"]) g["Path2D"] = class Path2D {};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
|
@ -15,6 +22,9 @@ export async function POST(request: NextRequest) {
|
|||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Dynamic import to ensure global polyfills run first
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
|
||||
// Extract text from PDF using PDFParse v2 API
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const pdfData = await parser.getText();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
|
|||
import { supabase } from "@/lib/supabase";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
|
|
@ -415,269 +416,294 @@ export default function InterviewsPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
<div className="flex flex-col lg:flex-row items-start w-full overflow-hidden">
|
||||
{/* COLUMN 1: Open Positions Sidebar */}
|
||||
<div className="lg:col-span-1 bg-white dark:bg-slate-900 p-4 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-1.5">
|
||||
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1">
|
||||
{t.openPositions}
|
||||
</h3>
|
||||
<div className={`transition-all duration-300 ease-in-out flex-shrink-0 ${
|
||||
selectedInterviewId
|
||||
? "w-0 opacity-0 overflow-hidden lg:pr-0 pointer-events-none"
|
||||
: "w-full lg:w-1/4 lg:pr-6 mb-6 lg:mb-0"
|
||||
}`}>
|
||||
<div className="bg-white dark:bg-slate-900 p-4 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-1.5">
|
||||
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1">
|
||||
{t.openPositions}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectJob(null)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
|
||||
selectedJobId === null
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
|
||||
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<span>{t.allPositions}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium">
|
||||
{interviews.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectJob(null)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
|
||||
selectedJobId === null
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
|
||||
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<span>{t.allPositions}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium">
|
||||
{interviews.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{jobs.map((job) => {
|
||||
const count = getInterviewCountForJob(job.id);
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => handleSelectJob(job.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
|
||||
selectedJobId === job.id
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
|
||||
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate mr-2" title={job.title}>{job.title}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium flex-shrink-0">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{jobs.map((job) => {
|
||||
const count = getInterviewCountForJob(job.id);
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => handleSelectJob(job.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
|
||||
selectedJobId === job.id
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
|
||||
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate mr-2" title={job.title}>{job.title}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium flex-shrink-0">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* COLUMN 2: Candidates List */}
|
||||
<div className="lg:col-span-1 bg-white dark:bg-slate-900 p-4 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-4">
|
||||
<div className="border-b border-slate-200 dark:border-slate-800 pb-2">
|
||||
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
{t.candidates}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`transition-all duration-300 ease-in-out flex-shrink-0 ${
|
||||
selectedInterviewId
|
||||
? "w-full lg:w-1/3 lg:pr-6 mb-6 lg:mb-0"
|
||||
: "w-full lg:w-1/4 lg:pr-6 mb-6 lg:mb-0"
|
||||
}`}>
|
||||
<div className="bg-white dark:bg-slate-900 p-4 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-4">
|
||||
<div className="border-b border-slate-200 dark:border-slate-800 pb-2">
|
||||
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
{t.candidates}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{sortedInterviews.length === 0 ? (
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm py-4 italic">
|
||||
{t.noCandidatesInStage}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedInterviews.map((interview) => (
|
||||
<div
|
||||
key={interview.id}
|
||||
onClick={() => setSelectedInterviewId(interview.id)}
|
||||
className={`cursor-pointer p-4 rounded-lg shadow-sm border transition duration-200 relative ${
|
||||
selectedInterviewId === interview.id
|
||||
? "bg-slate-50 dark:bg-slate-800/40 border-blue-300 dark:border-blue-800"
|
||||
: "bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700"
|
||||
} ${
|
||||
interview.pinned
|
||||
? "border-l-4 border-l-blue-600 pl-3"
|
||||
: "border-l border-l-slate-200 dark:border-l-slate-800 pl-4"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
||||
{interview.candidates?.name || t.unknownCandidate}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
|
||||
{interview.jobs?.title || t.unknownJob}
|
||||
</p>
|
||||
{sortedInterviews.length === 0 ? (
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm py-4 italic">
|
||||
{t.noCandidatesInStage}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedInterviews.map((interview) => (
|
||||
<div
|
||||
key={interview.id}
|
||||
onClick={() => setSelectedInterviewId(interview.id)}
|
||||
className={`cursor-pointer p-4 rounded-lg shadow-sm border transition duration-200 relative ${
|
||||
selectedInterviewId === interview.id
|
||||
? "bg-slate-50 dark:bg-slate-800/40 border-blue-300 dark:border-blue-800"
|
||||
: "bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700"
|
||||
} ${
|
||||
interview.pinned
|
||||
? "border-l-4 border-l-blue-600 pl-3"
|
||||
: "border-l border-l-slate-200 dark:border-l-slate-800 pl-4"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
||||
{interview.candidates?.name || t.unknownCandidate}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
|
||||
{interview.jobs?.title || t.unknownJob}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${
|
||||
interview.stage === "Hired"
|
||||
? "bg-green-100 dark:bg-green-950/40 text-green-800 dark:text-green-300"
|
||||
: interview.stage === "Rejected"
|
||||
? "bg-red-100 dark:bg-red-950/40 text-red-800 dark:text-red-300"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
||||
}`}>
|
||||
{translateStage(interview.stage, lang)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(interview.id, interview.pinned);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors cursor-pointer"
|
||||
title={interview.pinned ? t.unpinCandidate : t.pinCandidate}
|
||||
>
|
||||
{interview.pinned ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400">
|
||||
<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${
|
||||
interview.stage === "Hired"
|
||||
? "bg-green-100 dark:bg-green-950/40 text-green-800 dark:text-green-300"
|
||||
: interview.stage === "Rejected"
|
||||
? "bg-red-100 dark:bg-red-950/40 text-red-800 dark:text-red-300"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
||||
}`}>
|
||||
{translateStage(interview.stage, lang)}
|
||||
</span>
|
||||
{/* COLUMN 3: Candidate Details Pane */}
|
||||
<div className={`transition-all duration-300 ease-in-out flex-1 ${
|
||||
selectedInterviewId
|
||||
? "w-full lg:w-2/3"
|
||||
: "w-full lg:w-2/4"
|
||||
}`}>
|
||||
<div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-6 min-h-[300px]">
|
||||
{!selectedInterview ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 text-slate-300 dark:text-slate-600 mb-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium">
|
||||
{t.selectCandidateDetails}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Details Header */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-800 pb-4 flex flex-col gap-1.5">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
{selectedInterview.candidates?.name || t.unknownCandidate}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-slate-300 font-medium">
|
||||
<span>
|
||||
{t.role}: <span className="font-bold text-slate-800 dark:text-slate-200">{selectedInterview.jobs?.title || t.unknownJob}</span>
|
||||
</span>
|
||||
<span className="text-slate-300 dark:text-slate-700 hidden sm:inline">|</span>
|
||||
<span>
|
||||
{t.dateScheduled}: <span className="text-slate-500 dark:text-slate-400">{new Date(selectedInterview.interview_date).toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Dropdown Selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.stage}:
|
||||
</label>
|
||||
<select
|
||||
value={selectedInterview.stage}
|
||||
onChange={(e) => updateInterviewField(selectedInterview.id, { stage: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="Screening">{t.screening}</option>
|
||||
<option value="Technical">{t.technical}</option>
|
||||
<option value="Cultural">{t.cultural}</option>
|
||||
<option value="Offer">{t.offer}</option>
|
||||
<option value="Hired">{t.hired}</option>
|
||||
<option value="Rejected">{t.rejected}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Comments Thread System */}
|
||||
<div className="flex flex-col gap-4 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
{t.interviewFeedback}
|
||||
</h3>
|
||||
|
||||
{/* AI Suggestion Button */}
|
||||
<button
|
||||
onClick={handleGetAiSuggestion}
|
||||
disabled={suggesting || cooldownActive}
|
||||
title={cooldownActive ? t.cooldownMessage.replace("{hours}", String(cooldownHours)) : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-md border transition-all duration-200 cursor-pointer flex items-center gap-1.5 select-none ${
|
||||
cooldownActive
|
||||
? "bg-slate-50 dark:bg-slate-800/50 text-slate-400 border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-60"
|
||||
: "bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/20 dark:hover:bg-blue-950/45 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800/80"
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 21m0-12h.008v.008H9V9Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM12 10.5h.008v.008H12V10.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H11.625V13.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM15 9.75h.008v.008H15V9.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM18 12h.008v.008H18V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H17.625V15Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.379-3.379a3 3 0 0 0-4.242 4.242l4.242-4.242Z" />
|
||||
</svg>
|
||||
{suggesting ? t.suggesting : t.aiSuggestButton}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment List */}
|
||||
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-3">
|
||||
{selectedInterviewComments.length === 0 ? (
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs italic py-2">
|
||||
{t.noCommentsYet}
|
||||
</p>
|
||||
) : (
|
||||
selectedInterviewComments.map((comment) => {
|
||||
const isCollapsed = collapsedComments[comment.id] || false;
|
||||
const isAiComment = comment.isAi || comment.author === "AI Assistant" || comment.author === "Asistente IA";
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
onClick={() => toggleCommentCollapse(comment.id)}
|
||||
className={`p-3 rounded-lg border flex flex-col gap-1.5 transition-colors cursor-pointer select-none ${
|
||||
isAiComment
|
||||
? "bg-blue-50/20 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/50 hover:bg-blue-50/30 dark:hover:bg-blue-950/20"
|
||||
: "bg-slate-50 dark:bg-slate-800/40 border-slate-100 dark:border-slate-800/60 hover:bg-slate-100/40 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span className={`font-bold ${isAiComment ? "text-blue-600 dark:text-blue-400" : "text-slate-700 dark:text-slate-300"}`}>
|
||||
{comment.author || t.postedByAgent}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{new Date(comment.timestamp).toLocaleString()}
|
||||
</span>
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{isCollapsed ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment Input */}
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder={t.commentPlaceholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(interview.id, interview.pinned);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors cursor-pointer"
|
||||
title={interview.pinned ? t.unpinCandidate : t.pinCandidate}
|
||||
onClick={handleAddComment}
|
||||
disabled={!newComment.trim()}
|
||||
className="py-1.5 px-3.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition duration-200 cursor-pointer"
|
||||
>
|
||||
{interview.pinned ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400">
|
||||
<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
|
||||
</svg>
|
||||
)}
|
||||
{t.addComment}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* COLUMN 3: Candidate Details Pane */}
|
||||
<div className="lg:col-span-2 bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-6 min-h-[300px]">
|
||||
{!selectedInterview ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 text-slate-300 dark:text-slate-600 mb-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium">
|
||||
{t.selectCandidateDetails}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Details Header */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-800 pb-4 flex flex-col gap-1.5">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
{selectedInterview.candidates?.name || t.unknownCandidate}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-slate-300 font-medium">
|
||||
<span>
|
||||
{t.role}: <span className="font-bold text-slate-800 dark:text-slate-200">{selectedInterview.jobs?.title || t.unknownJob}</span>
|
||||
</span>
|
||||
<span className="text-slate-300 dark:text-slate-700 hidden sm:inline">|</span>
|
||||
<span>
|
||||
{t.dateScheduled}: <span className="text-slate-500 dark:text-slate-400">{new Date(selectedInterview.interview_date).toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Dropdown Selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.stage}:
|
||||
</label>
|
||||
<select
|
||||
value={selectedInterview.stage}
|
||||
onChange={(e) => updateInterviewField(selectedInterview.id, { stage: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="Screening">{t.screening}</option>
|
||||
<option value="Technical">{t.technical}</option>
|
||||
<option value="Cultural">{t.cultural}</option>
|
||||
<option value="Offer">{t.offer}</option>
|
||||
<option value="Hired">{t.hired}</option>
|
||||
<option value="Rejected">{t.rejected}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Comments Thread System */}
|
||||
<div className="flex flex-col gap-4 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
{t.interviewFeedback}
|
||||
</h3>
|
||||
|
||||
{/* AI Suggestion Button */}
|
||||
<button
|
||||
onClick={handleGetAiSuggestion}
|
||||
disabled={suggesting || cooldownActive}
|
||||
title={cooldownActive ? t.cooldownMessage.replace("{hours}", String(cooldownHours)) : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-md border transition-all duration-200 cursor-pointer flex items-center gap-1.5 select-none ${
|
||||
cooldownActive
|
||||
? "bg-slate-50 dark:bg-slate-800/50 text-slate-400 border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-60"
|
||||
: "bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/20 dark:hover:bg-blue-950/45 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800/80"
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 21m0-12h.008v.008H9V9Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM12 10.5h.008v.008H12V10.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H11.625V13.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM15 9.75h.008v.008H15V9.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM18 12h.008v.008H18V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H17.625V15Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.379-3.379a3 3 0 0 0-4.242 4.242l4.242-4.242Z" />
|
||||
</svg>
|
||||
{suggesting ? t.suggesting : t.aiSuggestButton}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment List */}
|
||||
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-3">
|
||||
{selectedInterviewComments.length === 0 ? (
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs italic py-2">
|
||||
{t.noCommentsYet}
|
||||
</p>
|
||||
) : (
|
||||
selectedInterviewComments.map((comment) => {
|
||||
const isCollapsed = collapsedComments[comment.id] || false;
|
||||
const isAiComment = comment.isAi || comment.author === "AI Assistant" || comment.author === "Asistente IA";
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={`p-3 rounded-lg border flex flex-col gap-1.5 transition-colors ${
|
||||
isAiComment
|
||||
? "bg-blue-50/20 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/50"
|
||||
: "bg-slate-50 dark:bg-slate-800/40 border-slate-100 dark:border-slate-800/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span className={`font-bold ${isAiComment ? "text-blue-600 dark:text-blue-400" : "text-slate-700 dark:text-slate-300"}`}>
|
||||
{comment.author || t.postedByAgent}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{new Date(comment.timestamp).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleCommentCollapse(comment.id)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 font-semibold cursor-pointer select-none"
|
||||
>
|
||||
{isCollapsed ? "[+]" : "[-]"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed markdown-content">
|
||||
<ReactMarkdown>{comment.text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment Input */}
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder={t.commentPlaceholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={!newComment.trim()}
|
||||
className="py-1.5 px-3.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition duration-200 cursor-pointer"
|
||||
>
|
||||
{t.addComment}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,12 +45,14 @@ export default function DashboardLayout({
|
|||
>
|
||||
{lang === "en" ? "Interviews" : "Entrevistas"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/workflows"
|
||||
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
|
||||
>
|
||||
{lang === "en" ? "Workflows" : "Flujos de Trabajo"}
|
||||
</Link>
|
||||
{process.env.NODE_ENV !== "production" && (
|
||||
<Link
|
||||
href="/workflows"
|
||||
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
|
||||
>
|
||||
{lang === "en" ? "Workflows" : "Flujos de Trabajo"}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
|
|
@ -47,6 +48,10 @@ const translations = {
|
|||
};
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { lang } = useApp();
|
||||
const t = translations[lang];
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || "";
|
||||
|
|
|
|||
17
app/api/auth/route.ts
Normal file
17
app/api/auth/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
const correctPassword = process.env.APP_PASSWORD || "Semillero2026!";
|
||||
|
||||
if (password === correctPassword) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: "Incorrect password" }, { status: 401 });
|
||||
} catch (error) {
|
||||
console.error("Auth API Error:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -33,3 +33,64 @@
|
|||
body {
|
||||
@apply bg-slate-50 text-slate-600 antialiased transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dark .markdown-content th,
|
||||
.dark .markdown-content td {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.dark .markdown-content th {
|
||||
background-color: #1e293b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .markdown-content tr:nth-child(even) {
|
||||
background-color: rgba(30, 41, 59, 0.3);
|
||||
}
|
||||
|
||||
.markdown-content ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-top: 0.125rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
12
app/page.tsx
12
app/page.tsx
|
|
@ -2,19 +2,19 @@ import Link from "next/link";
|
|||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-50 p-6">
|
||||
<div className="w-full max-w-md bg-white p-8 rounded-lg shadow-sm border border-slate-200 text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 transition-colors duration-200">
|
||||
<div className="w-full max-w-md bg-white dark:bg-slate-900 p-8 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 text-center transition-colors duration-200">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
AI Recruitment Platform
|
||||
</h1>
|
||||
<p className="text-slate-600 mb-6 text-sm">
|
||||
<p className="text-slate-600 dark:text-slate-300 mb-6 text-sm">
|
||||
Welcome to the ATS. Access your workspace below.
|
||||
</p>
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="inline-block w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm transition-colors text-sm"
|
||||
className="inline-block w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm transition-colors text-sm cursor-pointer"
|
||||
>
|
||||
Go to Dashboard
|
||||
Go to Workspace
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -14,15 +14,41 @@ interface AppContextType {
|
|||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
const loginTranslations = {
|
||||
en: {
|
||||
title: "Secure Access",
|
||||
subtitle: "Please enter the password to access the Semillero AI Recruitment Platform.",
|
||||
placeholder: "Enter password",
|
||||
button: "Unlock",
|
||||
error: "Incorrect password",
|
||||
required: "Password is required",
|
||||
unlocking: "Unlocking...",
|
||||
},
|
||||
es: {
|
||||
title: "Acceso Seguro",
|
||||
subtitle: "Por favor ingrese la contraseña para acceder a la Plataforma de Reclutamiento Semillero IA.",
|
||||
placeholder: "Ingrese la contraseña",
|
||||
button: "Desbloquear",
|
||||
error: "Contraseña incorrecta",
|
||||
required: "La contraseña es requerida",
|
||||
unlocking: "Desbloqueando...",
|
||||
}
|
||||
};
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Language>("en");
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [loadingAuth, setLoadingAuth] = useState(false);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedLang = localStorage.getItem("lang") as Language;
|
||||
const storedTheme = localStorage.getItem("theme") as Theme;
|
||||
const sessionTime = localStorage.getItem("auth_session_time");
|
||||
|
||||
setTimeout(() => {
|
||||
if (storedLang === "en" || storedLang === "es") {
|
||||
|
|
@ -38,6 +64,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||
setThemeState("dark");
|
||||
}
|
||||
}
|
||||
|
||||
// Check auth session: auto log off after 24 hours (1 day)
|
||||
if (sessionTime) {
|
||||
const lastSession = parseInt(sessionTime, 10);
|
||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||
if (Date.now() - lastSession < oneDayMs) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
localStorage.removeItem("auth_session_time");
|
||||
}
|
||||
}
|
||||
|
||||
setMounted(true);
|
||||
}, 0);
|
||||
}, []);
|
||||
|
|
@ -63,6 +101,128 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||
localStorage.setItem("theme", newTheme);
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setErrorMsg(lang === "es" ? loginTranslations.es.required : loginTranslations.en.required);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingAuth(true);
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem("auth_session_time", Date.now().toString());
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
await response.json();
|
||||
setErrorMsg(lang === "es" ? loginTranslations.es.error : loginTranslations.en.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication failed:", err);
|
||||
setErrorMsg("Connection error. Please try again.");
|
||||
} finally {
|
||||
setLoadingAuth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const t = lang === "es" ? loginTranslations.es : loginTranslations.en;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 transition-colors duration-200">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 transition-colors duration-200 relative font-sans">
|
||||
{/* Top-right Language and Theme Controls */}
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
{/* Language Selector */}
|
||||
<button
|
||||
onClick={() => setLang(lang === "en" ? "es" : "en")}
|
||||
className="px-2.5 py-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-md shadow-xs hover:bg-slate-50 dark:hover:bg-slate-800 transition duration-150 cursor-pointer"
|
||||
>
|
||||
{lang === "en" ? "Español (ES)" : "English (EN)"}
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-md shadow-xs hover:bg-slate-50 dark:hover:bg-slate-800 transition duration-150 cursor-pointer"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4.5 h-4.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4.5 h-4.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m0 13.5V21M4.22 4.22l1.59 1.59m12.38 12.38l1.59 1.59M3 12h2.25m13.5 0H21M6.09 18.36l1.59-1.59m12.38-12.38l-1.59 1.59M12 7.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Login Lock Card */}
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-sm p-8 max-w-sm w-full flex flex-col gap-5 transition-colors duration-200">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
{/* Shield Lock Icon */}
|
||||
<div className="w-12 h-12 bg-blue-50 dark:bg-blue-950/40 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0V10.5m-2.25 10.5h13.5c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H5.25c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white mt-1">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-normal max-w-[280px]">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t.placeholder}
|
||||
disabled={loadingAuth}
|
||||
className="w-full px-3.5 py-2 border border-slate-200 dark:border-slate-800 rounded-md text-slate-900 dark:text-white bg-slate-50 dark:bg-slate-800/40 text-sm focus:outline-none focus:border-blue-500 dark:focus:border-blue-500/80 transition duration-150"
|
||||
autoFocus
|
||||
/>
|
||||
{errorMsg && (
|
||||
<p className="text-[11px] text-red-600 dark:text-red-400 font-semibold mt-0.5">
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loadingAuth}
|
||||
className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-semibold text-sm rounded-md transition duration-200 cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{loadingAuth ? t.unlocking : t.button}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
|
||||
{children}
|
||||
|
|
|
|||
188
docs/setup-guide.md
Normal file
188
docs/setup-guide.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Setup and Configuration Guide: AI Recruitment Platform
|
||||
|
||||
This guide covers the complete step-by-step setup process for the database (Supabase), the automation engine (n8n), the AI models, and the Next.js application.
|
||||
|
||||
---
|
||||
|
||||
## 1. Supabase (Database Setup)
|
||||
|
||||
Supabase provides the relational database, vector store, and API services.
|
||||
|
||||
### A. Extensions Needed
|
||||
The semantic similarity matching uses vector calculations. You must enable the `vector` extension.
|
||||
* Go to **Database** -> **Extensions** -> Search for `vector` -> Click **Enable**.
|
||||
|
||||
### B. Table Schemas
|
||||
You need to create the following five tables. Run these SQL commands in the Supabase **SQL Editor**:
|
||||
|
||||
```sql
|
||||
-- 1. Jobs Table
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
requirements JSONB NOT NULL DEFAULT '{}'::jsonb, -- Store raw text + lowercase buzzwords list
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Candidates Table
|
||||
CREATE TABLE candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT,
|
||||
embedding VECTOR(1536), -- 1536 dimensions matching gemini-embedding-001
|
||||
contact_info JSONB DEFAULT '{}'::jsonb, -- Store A-Z summary, skills list, phone, etc.
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- 3. Scores Table (Decoupled Evaluations)
|
||||
CREATE TABLE scores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE NOT NULL,
|
||||
job_id UUID REFERENCES jobs(id) ON DELETE CASCADE NOT NULL,
|
||||
ai_score INTEGER NOT NULL CHECK (ai_score >= 0 AND ai_score <= 100),
|
||||
risk_level TEXT NOT NULL, -- 'Low', 'Medium', 'High'
|
||||
evaluation JSONB NOT NULL DEFAULT '{}'::jsonb, -- Store { summary, classification, suggestions }
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- 4. Interviews Table
|
||||
CREATE TABLE interviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE NOT NULL,
|
||||
job_id UUID REFERENCES jobs(id) ON DELETE CASCADE NOT NULL,
|
||||
interview_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
stage TEXT DEFAULT 'Screening'::text NOT NULL, -- 'Screening', 'Technical', 'Cultural', 'Offer', 'Hired', 'Rejected'
|
||||
feedback TEXT, -- Stores JSON string comments timeline array [{ id, text, timestamp, author, stage, isAi }]
|
||||
pinned BOOLEAN DEFAULT false NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### C. pgvector Similarity Match Function
|
||||
Next, create the PostgreSQL RPC function to rank candidates against job requirements using cosine distance. Run this in the SQL Editor:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION match_candidates (
|
||||
query_embedding VECTOR(1536),
|
||||
match_threshold DOUBLE PRECISION,
|
||||
match_count INT
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
contact_info JSONB,
|
||||
similarity DOUBLE PRECISION
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
candidates.id,
|
||||
candidates.name,
|
||||
candidates.email,
|
||||
candidates.phone,
|
||||
candidates.contact_info,
|
||||
1 - (candidates.embedding <=> query_embedding) AS similarity
|
||||
FROM candidates
|
||||
WHERE 1 - (candidates.embedding <=> query_embedding) > match_threshold
|
||||
ORDER BY candidates.embedding <=> query_embedding
|
||||
LIMIT match_count;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. n8n (Automation Engine Setup)
|
||||
|
||||
n8n acts as the central automation orchestrator, receiving hooks from Next.js, calling LLM chains, and saving responses.
|
||||
|
||||
### A. Minimum Requirements
|
||||
* **n8n instance**: Self-hosted (Docker / npm package) or n8n Cloud.
|
||||
* **Version**: n8n v1.0.0 or later (v1.30+ recommended for advanced LangChain integration features).
|
||||
* **Network Access**: The n8n instance must be publicly reachable (using domain/tunnel like ngrok/Cloudflare) so Vercel can post webhook payloads.
|
||||
|
||||
### B. Supported LLM Models & Providers
|
||||
The n8n Kickstarter script (`deploy-n8n-v2.ts`) supports credentials setup and deployment for:
|
||||
|
||||
| Provider | Choice ID | Default Model | Node Type | API Key Env Var |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Deepseek** | `1` | `deepseek-chat` | `@n8n/n8n-nodes-langchain.lmChatDeepSeek` | `DEEPSEEK_API_KEY` |
|
||||
| **OpenAI** | `2` | `gpt-4o-mini` | `@n8n/n8n-nodes-langchain.lmChatOpenAi` | `OPENAI_API_KEY` |
|
||||
| **Google Gemini** | `3` | `gemini-1.5-flash` | `@n8n/n8n-nodes-langchain.lmChatGoogleGemini` | `GEMINI_API_KEY` |
|
||||
| **Anthropic** | `4` | `claude-3-5-sonnet-latest` | `@n8n/n8n-nodes-langchain.lmChatAnthropic` | `ANTHROPIC_API_KEY` |
|
||||
|
||||
### C. Deploying via Script
|
||||
1. Configure the `.env` variables (`N8N_HOST`, `N8N_API_KEY`).
|
||||
2. Run the deployment script:
|
||||
```bash
|
||||
npx ts-node -O '{"module": "commonjs"}' scripts/deploy-n8n-v2.ts \
|
||||
--primary-provider=deepseek \
|
||||
--fallback \
|
||||
--fallback-provider=gemini
|
||||
```
|
||||
*Note: The script dynamically detects if a credential exists in n8n. If found, it safely reuses the credential to avoid entering keys repeatedly.*
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported Webhooks / API Endpoints
|
||||
|
||||
The n8n workflow exposes the following endpoints (automatically registered upon deployment):
|
||||
|
||||
### 1. `POST /webhook/evaluate-candidate`
|
||||
Triggered by Next.js when parsing CVs or running AI evaluations.
|
||||
* **Payload**:
|
||||
```json
|
||||
{
|
||||
"candidateId": "uuid-here",
|
||||
"jobId": "uuid-here",
|
||||
"candidateName": "John Doe",
|
||||
"jobTitle": "React Developer",
|
||||
"jobRequirements": "Stack: React, TypeScript, Tailwind...",
|
||||
"text": "Extracted text contents of candidate resume..."
|
||||
}
|
||||
```
|
||||
* **Workflow Operations**:
|
||||
* Extracts resume data (Structured Output Parser).
|
||||
* Inserts parsed details (skills list, summary) into `candidates` table.
|
||||
* Runs the primary evaluation chain (Deepseek) with a fallback to Google Gemini.
|
||||
* Calculates calibrated scores (0-100), risk classifications (`Qualified`, `Unqualified`, `Review`), and appends next-step suggestions.
|
||||
* Stores result in `scores` table.
|
||||
|
||||
### 2. `POST /webhook/suggest-next-steps`
|
||||
Triggered by Next.js when clicking "Get AI Suggestion" inside Interviews.
|
||||
* **Payload**:
|
||||
```json
|
||||
{
|
||||
"candidateName": "John Doe",
|
||||
"jobTitle": "React Developer",
|
||||
"currentStage": "Technical",
|
||||
"candidateSummary": "Extracted summary...",
|
||||
"candidateSkills": ["react", "typescript"],
|
||||
"jobRequirements": "Stack: React...",
|
||||
"commentHistory": [ ... ],
|
||||
"lang": "en" | "es"
|
||||
}
|
||||
```
|
||||
* **Workflow Operations**:
|
||||
* Assembles context of the candidate, job description, and interview timeline.
|
||||
* Queries LLM to output a brief, actionable list of next steps.
|
||||
* Instructs LLM to write in Spanish if `lang` is `"es"`, otherwise English.
|
||||
|
||||
---
|
||||
|
||||
## 4. Next.js (Application Setup)
|
||||
|
||||
### A. Environment variables
|
||||
Make sure all items in `.env` are configured:
|
||||
* `APP_PASSWORD`: Protects the lock screen. Default fallback is `Semillero2026!`.
|
||||
* `GEMINI_API_KEY`: Mandatory for client-side embedding generation (`models/gemini-embedding-001`) and candidate comparisons.
|
||||
* `NEXT_PUBLIC_SUPABASE_URL` & `SUPABASE_SECRET_KEY`: Service role keys to read/write without RLS checks.
|
||||
|
||||
### B. Deployment
|
||||
Deploy to Vercel with all environment variables matching your local setup. Ensure the API routes can freely reach the database and the n8n webhooks.
|
||||
|
|
@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["pdf-parse"],
|
||||
outputFileTracingIncludes: {
|
||||
"/*": ["./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
296
package-lock.json
generated
296
package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
|||
"pdf-parse": "^2.4.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0"
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -5523,6 +5524,16 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -5533,6 +5544,34 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||
|
|
@ -5557,6 +5596,107 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
||||
"mdast-util-gfm-footnote": "^2.0.0",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"micromark-util-character": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-strikethrough": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-table": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"markdown-table": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-task-list-item": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
|
|
@ -5765,6 +5905,127 @@
|
|||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
||||
"micromark-extension-gfm-table": "^2.0.0",
|
||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-chunked": "^2.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-resolve-all": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
|
|
@ -6840,6 +7101,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
|
|
@ -6873,6 +7152,21 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-stringify": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
"pdf-parse": "^2.4.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0"
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
|
|||
const credsList = await n8nRequest("/api/v1/credentials");
|
||||
const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type);
|
||||
if (existingCred) {
|
||||
console.log(`Deleting existing credential: ${name} (ID: ${existingCred.id})...`);
|
||||
await n8nRequest(`/api/v1/credentials/${existingCred.id}`, "DELETE");
|
||||
console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})...`);
|
||||
return existingCred.id;
|
||||
}
|
||||
|
||||
const newCred = await n8nRequest("/api/v1/credentials", "POST", {
|
||||
|
|
@ -300,7 +300,7 @@ async function main() {
|
|||
enableFallbackModel: configureFallback,
|
||||
hasFallbackModel: configureFallback,
|
||||
text: "=Candidate CV Text:\n{{ $('Webhook Trigger').item.json.body.text }}\n\nTarget Job Vacancy:\nTitle: {{ $('Webhook Trigger').item.json.body.jobTitle }}\nRequirements:\n{{ $('Webhook Trigger').item.json.body.jobRequirements }}",
|
||||
systemMessage: "You are an AI recruitment evaluation expert. Your job is to carefully assess if the candidate qualifies for the specific job vacancy. Evaluate their CV text against the target Job Title and Job Requirements. Be objective: if the candidate does not have the core stack, experience, or skills required for this specific job, they MUST be classified as 'Unqualified' with a low suitability score.\n\nSuitability Score (ai_score) Calibration Rules:\n- If the candidate does not match the vacancy at all, or lacks all core technical skills required for the job, the score MUST be extremely low (between 0 and 15). Never give a middle score like 50 to a complete mismatch.\n- If the candidate has minor overlaps but lacks the core tech stack/experience, the score MUST be below 50.\n- If the candidate is a borderline or partial fit (50-74% match), the score must be between 50 and 74.\n- Only candidates who are highly qualified and match the core stack and experience should receive a score of 75 or higher.\n\nYou MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief evaluation summary explaining why they match or fail to match the specific job requirements (max 3 sentences).\n- classification: 'Qualified' (if they match the requirements well), 'Unqualified' (if they lack critical skills/stack for this specific job), or 'Review' (if they are a borderline match).\n- suggestions: an array of recommendations (e.g. ['Schedule technical interview', 'Reject', 'Verify experience with X']).\n- riskLevel: 'Low', 'Medium', or 'High' (suitability/fit risk).\n- ai_score: an integer between 0 and 100 representing suitability for this specific job. Return a whole integer (do NOT return a decimal fraction like 0.88, return an integer like 88).",
|
||||
systemMessage: "You are an AI recruitment assistant. Your job is to carefully assess if the candidate qualifies for the specific job vacancy. Evaluate their CV text against the target Job Title and Job Requirements. Be objective: if the candidate does not have the core stack, experience, or skills required for this specific job, they MUST be classified as 'Unqualified' with a low suitability score.\n\nSuitability Score (ai_score) Calibration Rules:\n- If the candidate does not match the vacancy at all, or lacks all core technical skills required for the job, the score MUST be extremely low (between 0 and 15). Never give a middle score like 50 to a complete mismatch.\n- If the candidate has minor overlaps but lacks the core tech stack/experience, the score MUST be below 50.\n- If the candidate is a borderline or partial fit (50-74% match), the score must be between 50 and 74.\n- Only candidates who are highly qualified and match the core stack and experience should receive a score of 75 or higher.\n\nYou MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief evaluation summary explaining why they match or fail to match the specific job requirements (max 3 sentences).\n- classification: 'Qualified' (if they match the requirements well), 'Unqualified' (if they lack critical skills/stack for this specific job), or 'Review' (if they are a borderline match).\n- suggestions: an array of recommendations (e.g. ['Schedule technical interview', 'Reject', 'Verify experience with X']).\n- riskLevel: 'Low', 'Medium', or 'High' (suitability/fit risk).\n- ai_score: an integer between 0 and 100 representing suitability for this specific job. Return a whole integer (do NOT return a decimal fraction like 0.88, return an integer like 88).",
|
||||
},
|
||||
id: "eval-llm-chain",
|
||||
name: "LLM Chain Evaluation (Primary)",
|
||||
|
|
|
|||
Loading…
Reference in a new issue