Compare commits

..

No commits in common. "f429277fc33d0b61a3bf0368f18761fd8ac59b97" and "88815f6895d4df449c69efa2252c44764342d8c6" have entirely different histories.

14 changed files with 283 additions and 1131 deletions

121
README.md
View file

@ -1,107 +1,26 @@
# Semillero AI Recruitment Platform (ATS) # AI Recruitment Platform (ATS)
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. ## 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.
--- ## 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.
## 🔒 Security & Access Control ## Setup & Prerequisites
The application is protected by a global secure access gate at startup. ### 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`).
* **Security Lock Screen**: Any access to the platform redirects to a bilingual lock screen requiring a password. * **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/).
* **Encrypted Transmission**: The password is submitted encrypted over HTTPS to a secure server-side endpoint `/api/auth`. * **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.
* **Environment Configuration**: Store your password in the `.env` file under `APP_PASSWORD`. * **Configuration**: Add your key to the `.env` file at the root of the project:
* **Default Fallback**: If no `APP_PASSWORD` env variable is set, the system defaults to: ```env
GEMINI_API_KEY=your_google_ai_studio_api_key_here
``` ```
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.

View file

@ -1,16 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase"; import { createServerSupabaseClient } from "@/lib/supabase";
import { generateEmbedding } from "@/lib/embeddings"; import { generateEmbedding } from "@/lib/embeddings";
import { PDFParse } from "pdf-parse";
import { extractCandidateProfile } from "@/lib/gemini"; 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) { export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
@ -22,9 +15,6 @@ 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);
// Dynamic import to ensure global polyfills run first
const { PDFParse } = await import("pdf-parse");
// Extract text from PDF using PDFParse v2 API // Extract text from PDF using PDFParse v2 API
const parser = new PDFParse({ data: buffer }); const parser = new PDFParse({ data: buffer });
const pdfData = await parser.getText(); const pdfData = await parser.getText();

View file

@ -4,7 +4,6 @@ import React, { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { useApp } from "@/components/AppContext"; import { useApp } from "@/components/AppContext";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface Comment { interface Comment {
id: string; id: string;
@ -416,294 +415,269 @@ export default function InterviewsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="flex flex-col lg:flex-row items-start w-full overflow-hidden"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* COLUMN 1: Open Positions Sidebar */} {/* COLUMN 1: Open Positions Sidebar */}
<div className={`transition-all duration-300 ease-in-out flex-shrink-0 ${ <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">
selectedInterviewId <h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1">
? "w-0 opacity-0 overflow-hidden lg:pr-0 pointer-events-none" {t.openPositions}
: "w-full lg:w-1/4 lg:pr-6 mb-6 lg:mb-0" </h3>
}`}>
<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"> <button
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1"> onClick={() => handleSelectJob(null)}
{t.openPositions} className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
</h3> selectedJobId === null
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
<button : "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
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 <span>{t.allPositions}</span>
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium" <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">
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50" {interviews.length}
}`} </span>
> </button>
<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) => { {jobs.map((job) => {
const count = getInterviewCountForJob(job.id); const count = getInterviewCountForJob(job.id);
return ( return (
<button <button
key={job.id} key={job.id}
onClick={() => handleSelectJob(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 ${ 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 selectedJobId === job.id
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium" ? "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" : "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="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"> <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} {count}
</span> </span>
</button> </button>
); );
})} })}
</div>
</div> </div>
{/* COLUMN 2: Candidates List */} {/* COLUMN 2: Candidates List */}
<div className={`transition-all duration-300 ease-in-out flex-shrink-0 ${ <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">
selectedInterviewId <div className="border-b border-slate-200 dark:border-slate-800 pb-2">
? "w-full lg:w-1/3 lg:pr-6 mb-6 lg:mb-0" <h3 className="text-sm font-bold text-slate-900 dark:text-white">
: "w-full lg:w-1/4 lg:pr-6 mb-6 lg:mb-0" {t.candidates}
}`}> </h3>
<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>
</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>
{/* COLUMN 3: Candidate Details Pane */} {sortedInterviews.length === 0 ? (
<div className={`transition-all duration-300 ease-in-out flex-1 ${ <p className="text-slate-500 dark:text-slate-400 text-sm py-4 italic">
selectedInterviewId {t.noCandidatesInStage}
? "w-full lg:w-2/3" </p>
: "w-full lg:w-2/4" ) : (
}`}> <div className="flex flex-col gap-3">
<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]"> {sortedInterviews.map((interview) => (
{!selectedInterview ? ( <div
<div className="flex-1 flex flex-col items-center justify-center text-center py-12"> key={interview.id}
<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"> onClick={() => setSelectedInterviewId(interview.id)}
<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" /> className={`cursor-pointer p-4 rounded-lg shadow-sm border transition duration-200 relative ${
</svg> selectedInterviewId === interview.id
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium"> ? "bg-slate-50 dark:bg-slate-800/40 border-blue-300 dark:border-blue-800"
{t.selectCandidateDetails} : "bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700"
</p> } ${
</div> interview.pinned
) : ( ? "border-l-4 border-l-blue-600 pl-3"
<div className="flex flex-col gap-6"> : "border-l border-l-slate-200 dark:border-l-slate-800 pl-4"
{/* 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"> <div className="flex items-start justify-between gap-2">
{selectedInterview.candidates?.name || t.unknownCandidate} <div className="flex-1 min-w-0">
</h2> <h4 className="text-sm font-bold text-slate-900 dark:text-white truncate">
{interview.candidates?.name || t.unknownCandidate}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-slate-300 font-medium"> </h4>
<span> <p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{t.role}: <span className="font-bold text-slate-800 dark:text-slate-200">{selectedInterview.jobs?.title || t.unknownJob}</span> {interview.jobs?.title || t.unknownJob}
</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> </p>
) : ( </div>
selectedInterviewComments.map((comment) => {
const isCollapsed = collapsedComments[comment.id] || false; <div className="flex items-center gap-1.5 flex-shrink-0">
const isAiComment = comment.isAi || comment.author === "AI Assistant" || comment.author === "Asistente IA"; <span className={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${
return ( interview.stage === "Hired"
<div ? "bg-green-100 dark:bg-green-950/40 text-green-800 dark:text-green-300"
key={comment.id} : interview.stage === "Rejected"
onClick={() => toggleCommentCollapse(comment.id)} ? "bg-red-100 dark:bg-red-950/40 text-red-800 dark:text-red-300"
className={`p-3 rounded-lg border flex flex-col gap-1.5 transition-colors cursor-pointer select-none ${ : "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
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" {translateStage(interview.stage, lang)}
: "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" </span>
}`}
>
<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 <button
onClick={handleAddComment} onClick={(e) => {
disabled={!newComment.trim()} e.stopPropagation();
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" 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}
> >
{t.addComment} {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> </button>
</div> </div>
</div> </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> </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>
)} )}
</div> </div>
); );
} }

View file

@ -45,14 +45,12 @@ export default function DashboardLayout({
> >
{lang === "en" ? "Interviews" : "Entrevistas"} {lang === "en" ? "Interviews" : "Entrevistas"}
</Link> </Link>
{process.env.NODE_ENV !== "production" && ( <Link
<Link href="/workflows"
href="/workflows" className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
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"}
{lang === "en" ? "Workflows" : "Flujos de Trabajo"} </Link>
</Link>
)}
</nav> </nav>
</div> </div>

View file

@ -2,7 +2,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useApp } from "@/components/AppContext"; import { useApp } from "@/components/AppContext";
import { notFound } from "next/navigation";
const translations = { const translations = {
en: { en: {
@ -48,10 +47,6 @@ const translations = {
}; };
export default function WorkflowsPage() { export default function WorkflowsPage() {
if (process.env.NODE_ENV === "production") {
notFound();
}
const { lang } = useApp(); const { lang } = useApp();
const t = translations[lang]; const t = translations[lang];
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || ""; const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || "";

View file

@ -1,17 +0,0 @@
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 });
}
}

View file

@ -33,64 +33,3 @@
body { body {
@apply bg-slate-50 text-slate-600 antialiased transition-colors duration-200; @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;
}

View file

@ -2,19 +2,19 @@ import Link from "next/link";
export default function Home() { export default function Home() {
return ( return (
<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"> <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 dark:bg-slate-900 p-8 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 text-center transition-colors duration-200"> <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 dark:text-white mb-2"> <h1 className="text-2xl font-bold text-slate-900 mb-2">
AI Recruitment Platform AI Recruitment Platform
</h1> </h1>
<p className="text-slate-600 dark:text-slate-300 mb-6 text-sm"> <p className="text-slate-600 mb-6 text-sm">
Welcome to the ATS. Access your workspace below. Welcome to the ATS. Access your workspace below.
</p> </p>
<Link <Link
href="/jobs" 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 cursor-pointer" 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"
> >
Go to Workspace Go to Dashboard
</Link> </Link>
</div> </div>
</main> </main>

View file

@ -14,41 +14,15 @@ interface AppContextType {
const AppContext = createContext<AppContextType | undefined>(undefined); 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 }) { export function AppProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Language>("en"); const [lang, setLangState] = useState<Language>("en");
const [theme, setThemeState] = useState<Theme>("light"); const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false); 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 // Load from localStorage on mount
useEffect(() => { useEffect(() => {
const storedLang = localStorage.getItem("lang") as Language; const storedLang = localStorage.getItem("lang") as Language;
const storedTheme = localStorage.getItem("theme") as Theme; const storedTheme = localStorage.getItem("theme") as Theme;
const sessionTime = localStorage.getItem("auth_session_time");
setTimeout(() => { setTimeout(() => {
if (storedLang === "en" || storedLang === "es") { if (storedLang === "en" || storedLang === "es") {
@ -64,18 +38,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setThemeState("dark"); 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); setMounted(true);
}, 0); }, 0);
}, []); }, []);
@ -101,128 +63,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem("theme", newTheme); 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 ( return (
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}> <AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
{children} {children}

View file

@ -1,188 +0,0 @@
# 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.

View file

@ -2,9 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: ["pdf-parse"], serverExternalPackages: ["pdf-parse"],
outputFileTracingIncludes: {
"/*": ["./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs"],
},
}; };
export default nextConfig; export default nextConfig;

296
package-lock.json generated
View file

@ -13,8 +13,7 @@
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0"
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -5524,16 +5523,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5544,34 +5533,6 @@
"node": ">= 0.4" "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": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@ -5596,107 +5557,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/mdast-util-mdx-expression": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@ -5905,127 +5765,6 @@
"micromark-util-types": "^2.0.0" "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": { "node_modules/micromark-factory-destination": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@ -7101,24 +6840,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@ -7152,21 +6873,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/resolve": {
"version": "2.0.0-next.7", "version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",

View file

@ -14,8 +14,7 @@
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0"
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View file

@ -53,8 +53,8 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
const credsList = await n8nRequest("/api/v1/credentials"); const credsList = await n8nRequest("/api/v1/credentials");
const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type); const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type);
if (existingCred) { if (existingCred) {
console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})...`); console.log(`Deleting existing credential: ${name} (ID: ${existingCred.id})...`);
return existingCred.id; await n8nRequest(`/api/v1/credentials/${existingCred.id}`, "DELETE");
} }
const newCred = await n8nRequest("/api/v1/credentials", "POST", { const newCred = await n8nRequest("/api/v1/credentials", "POST", {
@ -300,7 +300,7 @@ async function main() {
enableFallbackModel: configureFallback, enableFallbackModel: configureFallback,
hasFallbackModel: 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 }}", 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 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).", 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).",
}, },
id: "eval-llm-chain", id: "eval-llm-chain",
name: "LLM Chain Evaluation (Primary)", name: "LLM Chain Evaluation (Primary)",