diff --git a/2synthesize-recruitly/HrFlow/backend/__init__.py b/2synthesize-recruitly/HrFlow/backend/__init__.py new file mode 100644 index 0000000..b292e02 --- /dev/null +++ b/2synthesize-recruitly/HrFlow/backend/__init__.py @@ -0,0 +1 @@ +"""Backend package for the AI Candidate Synthesis Agent.""" diff --git a/2synthesize-recruitly/HrFlow/backend/jobs_fallback.json b/2synthesize-recruitly/HrFlow/backend/jobs_fallback.json new file mode 100644 index 0000000..2a32069 --- /dev/null +++ b/2synthesize-recruitly/HrFlow/backend/jobs_fallback.json @@ -0,0 +1,62 @@ +[ + { + "key": "391db21d-fc9f-4db5-b281-ff8005548735", + "title": "Senior Python Backend Engineer", + "skills": ["docker", "fastapi", "postgresql", "rest apis", "python", "microservices", "communication", "teamwork", "problem solving", "autonomy"], + "summary": "Senior backend engineer role focused on Python, FastAPI microservices, PostgreSQL and containerized deployments." + }, + { + "key": "a7c142e8-3d21-4f90-b6e5-cc9012345678", + "title": "Full Stack Developer", + "skills": ["react", "typescript", "node.js", "postgresql", "rest apis", "git", "communication", "adaptability", "teamwork", "creativity"], + "summary": "Full stack developer building modern web applications with React frontend and Node.js backend." + }, + { + "key": "b2d389fa-91cc-4a77-8f31-dd4456789abc", + "title": "DevOps Engineer", + "skills": ["docker", "kubernetes", "ci/cd", "gitlab", "prometheus", "linux", "bash", "rigor", "autonomy", "stress management"], + "summary": "DevOps engineer responsible for CI/CD pipelines, container orchestration and infrastructure monitoring." + }, + { + "key": "c4e501bc-77ab-4b88-9e42-ee5567890def", + "title": "Data Engineer", + "skills": ["python", "sql", "spark", "airflow", "postgresql", "data pipelines", "analytical thinking", "rigor", "communication"], + "summary": "Data engineer designing and maintaining scalable data pipelines and analytics infrastructure." + }, + { + "key": "d5f612cd-88bc-4c99-af53-ff6678901ghi", + "title": "HR Business Partner", + "skills": ["talent acquisition", "employee relations", "performance management", "hris", "onboarding", "labor law", "empathy", "active listening", "conflict resolution", "leadership"], + "summary": "HR Business Partner supporting managers and employees across the full employee lifecycle." + }, + { + "key": "e6a723de-99cd-4d00-bg64-aa7789012jkl", + "title": "Talent Acquisition Specialist", + "skills": ["sourcing", "interviewing", "ats tools", "employer branding", "linkedin recruiter", "negotiation", "communication", "organization", "persuasion", "empathy"], + "summary": "Recruiter responsible for sourcing, screening and hiring top talent across technical and business roles." + }, + { + "key": "f7b834ef-00de-4e11-ch75-bb8890123mno", + "title": "Financial Analyst", + "skills": ["financial modeling", "excel", "sql", "reporting", "budget management", "forecasting", "powerbi", "rigor", "analytical thinking", "attention to detail"], + "summary": "Financial analyst responsible for budgeting, forecasting and financial reporting across business units." + }, + { + "key": "g8c945fa-11ef-4f22-di86-cc9901234pqr", + "title": "Finance Controller", + "skills": ["accounting", "ifrs", "consolidation", "erp", "excel", "audit", "tax compliance", "rigor", "leadership", "communication"], + "summary": "Finance controller overseeing accounting operations, financial close process and compliance reporting." + }, + { + "key": "h9d056gb-22fa-4a33-ej97-dd0012345stu", + "title": "Product Manager", + "skills": ["product strategy", "roadmap planning", "agile", "jira", "user research", "data analysis", "leadership", "communication", "prioritization", "stakeholder management"], + "summary": "Product Manager driving product vision, roadmap and delivery in close collaboration with engineering and design." + }, + { + "key": "i0e167hc-33ab-4b44-fk08-ee1123456vwx", + "title": "UX/UI Designer", + "skills": ["figma", "user research", "wireframing", "prototyping", "design systems", "accessibility", "creativity", "empathy", "communication", "attention to detail"], + "summary": "UX/UI designer crafting intuitive user experiences from research to high-fidelity prototypes." + } +] diff --git a/2synthesize-recruitly/HrFlow/backend/main.py b/2synthesize-recruitly/HrFlow/backend/main.py index e479b4e..ef35d94 100644 --- a/2synthesize-recruitly/HrFlow/backend/main.py +++ b/2synthesize-recruitly/HrFlow/backend/main.py @@ -12,7 +12,8 @@ import json as _json import os -from typing import Any, Dict +from contextlib import asynccontextmanager +from typing import Any, Dict, List from dotenv import load_dotenv from fastapi import FastAPI, File, Form, HTTPException, UploadFile @@ -20,12 +21,28 @@ load_dotenv() -from schemas import InterviewInput -from services.fusion_service import build_fusion_object -from services.hrflow_service import parse_and_score, parse_cv -from services.llm_service import extract_interview_signals, generate_synthesis +try: + from .schemas import InterviewInput + from .services.fusion_service import build_fusion_object + from .services.hrflow_service import parse_and_score, parse_cv + from .services.jobs_service import get_jobs, load_jobs_from_file + from .services.llm_service import extract_candidate_name, extract_interview_signals, generate_synthesis +except ImportError: + from schemas import InterviewInput + from services.fusion_service import build_fusion_object + from services.hrflow_service import parse_and_score, parse_cv + from services.jobs_service import get_jobs, load_jobs_from_file + from services.llm_service import extract_candidate_name, extract_interview_signals, generate_synthesis, parse_test_sheet -app = FastAPI(title="AI Candidate Synthesis Agent", version="1.0.0") + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Load jobs from local JSON file on startup.""" + load_jobs_from_file() + yield + + +app = FastAPI(title="AI Candidate Synthesis Agent", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -48,7 +65,9 @@ async def parse_cv_endpoint( """ Receive a CV file, forward it to HrFlow, return structured profile data. Uses HrFlow POST /v1/profile/parsing/file. + Name is extracted independently via LLM to avoid HrFlow name parsing bugs. """ + import io content = await file.read() try: result = await parse_cv( @@ -60,8 +79,29 @@ async def parse_cv_endpoint( except Exception as exc: raise HTTPException(status_code=502, detail=f"HrFlow parsing failed: {exc}") + # Extract candidate name via LLM from raw PDF text (more reliable than HrFlow) + full_name = result["full_name"] + try: + filename_lower = (file.filename or "").lower() + if filename_lower.endswith(".pdf") or file.content_type == "application/pdf": + from pypdf import PdfReader + reader = PdfReader(io.BytesIO(content)) + cv_text = "\n".join(p.extract_text() or "" for p in reader.pages) + else: + cv_text = content.decode("utf-8", errors="ignore") + + if cv_text.strip(): + llm_name = await extract_candidate_name(cv_text) + if llm_name: + full_name = llm_name + except Exception: + pass # keep HrFlow name as fallback + return { "profile_key": result["profile_key"], + "full_name": full_name, + "first_name": result["first_name"], + "last_name": result["last_name"], "skills": result["skills"], "experience_count": len(result["experiences"]), "education_count": len(result["educations"]), @@ -134,6 +174,44 @@ async def generate_candidate_synthesis(payload: dict) -> Dict[str, Any]: return report +# --------------------------------------------------------------------------- +# 14.X — Parse Test Sheet +# --------------------------------------------------------------------------- + +@app.post("/api/test/parse") +async def parse_test_sheet_endpoint(file: UploadFile = File(...)) -> Dict[str, Any]: + """ + Parse a technical test sheet (PDF or text) and extract structured scores. + Returns scores dict (category.skill → 1-5) and detected target_skills list. + """ + import io + content = await file.read() + filename = (file.filename or "").lower() + + if filename.endswith(".pdf") or file.content_type == "application/pdf": + try: + from pypdf import PdfReader + reader = PdfReader(io.BytesIO(content)) + text = "\n".join(p.extract_text() or "" for p in reader.pages) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Could not read PDF: {exc}") + else: + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(status_code=422, detail="File must be a PDF or UTF-8 text file") + + if not text.strip(): + raise HTTPException(status_code=422, detail="Could not extract text from file") + + try: + result = await parse_test_sheet(text) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"LLM test parsing failed: {exc}") + + return result + + # --------------------------------------------------------------------------- # 14.5 — Full Pipeline (main demo endpoint) # --------------------------------------------------------------------------- @@ -229,6 +307,19 @@ async def full_pipeline( } +# --------------------------------------------------------------------------- +# Jobs list (autocomplete) +# --------------------------------------------------------------------------- + +@app.get("/api/jobs/list") +async def jobs_list() -> Dict[str, Any]: + """ + Return all cached jobs for frontend autocomplete. + Each item: { key, title, skills, summary } + """ + return {"jobs": get_jobs()} + + # --------------------------------------------------------------------------- # Health check # --------------------------------------------------------------------------- diff --git a/2synthesize-recruitly/HrFlow/backend/requirements.txt b/2synthesize-recruitly/HrFlow/backend/requirements.txt index 5363a44..48f678b 100644 --- a/2synthesize-recruitly/HrFlow/backend/requirements.txt +++ b/2synthesize-recruitly/HrFlow/backend/requirements.txt @@ -6,3 +6,4 @@ pydantic>=2.7.0 anthropic>=0.28.0 openai>=1.30.0 python-dotenv>=1.0.0 +pypdf>=4.0.0 diff --git a/2synthesize-recruitly/HrFlow/backend/schemas.py b/2synthesize-recruitly/HrFlow/backend/schemas.py index 07dd757..6517735 100644 --- a/2synthesize-recruitly/HrFlow/backend/schemas.py +++ b/2synthesize-recruitly/HrFlow/backend/schemas.py @@ -120,3 +120,4 @@ class CandidateSynthesisReport(BaseModel): behavioral_assessment: str consistency_analysis: str justification: str + domain_fit: str diff --git a/2synthesize-recruitly/HrFlow/backend/services/hrflow_service.py b/2synthesize-recruitly/HrFlow/backend/services/hrflow_service.py index 586d412..77f18f6 100644 --- a/2synthesize-recruitly/HrFlow/backend/services/hrflow_service.py +++ b/2synthesize-recruitly/HrFlow/backend/services/hrflow_service.py @@ -58,7 +58,21 @@ async def parse_cv( body = response.json() parsing = body.get("data", {}).get("parsing", {}) - profile_key = body.get("data", {}).get("profile", {}).get("key", "") + profile = body.get("data", {}).get("profile", {}) + profile_key = profile.get("key", "") + + # Candidate name from profile info + info = profile.get("info", {}) + raw_full = info.get("full_name", "") + first_name = info.get("first_name", "") + last_name = info.get("last_name", "") + # Prefer building from first+last (more reliable than HrFlow full_name which can be shuffled) + if first_name and last_name: + full_name = f"{first_name} {last_name}" + elif first_name or last_name: + full_name = (first_name or last_name).strip() + else: + full_name = raw_full # Flatten skill names skills: List[str] = [ @@ -67,6 +81,9 @@ async def parse_cv( return { "profile_key": profile_key, + "full_name": full_name, + "first_name": first_name, + "last_name": last_name, "skills": skills, "experiences": parsing.get("experiences", []), "educations": parsing.get("educations", []), diff --git a/2synthesize-recruitly/HrFlow/backend/services/jobs_service.py b/2synthesize-recruitly/HrFlow/backend/services/jobs_service.py new file mode 100644 index 0000000..6718d4f --- /dev/null +++ b/2synthesize-recruitly/HrFlow/backend/services/jobs_service.py @@ -0,0 +1,37 @@ +""" +Jobs service — loads jobs from local JSON file. + +No HrFlow API call needed. The jobs_fallback.json at the backend root +is the source of truth for autocomplete and skill hints. +""" + +import json +import os +from typing import Any, Dict, List, Optional + +_JOBS_FILE = os.path.join(os.path.dirname(__file__), "..", "jobs_fallback.json") + +_jobs_cache: List[Dict[str, Any]] = [] + + +def load_jobs_from_file() -> None: + """Load jobs from local JSON file into memory.""" + global _jobs_cache + try: + with open(_JOBS_FILE, "r", encoding="utf-8") as f: + _jobs_cache = json.load(f) + print(f"[jobs_service] Loaded {len(_jobs_cache)} jobs from local file.") + except Exception as exc: + print(f"[jobs_service] Could not load jobs_fallback.json: {exc}") + _jobs_cache = [] + + +def get_jobs() -> List[Dict[str, Any]]: + return _jobs_cache + + +def get_job_by_key(job_key: str) -> Optional[Dict[str, Any]]: + for job in _jobs_cache: + if job["key"] == job_key: + return job + return None diff --git a/2synthesize-recruitly/HrFlow/backend/services/llm_service.py b/2synthesize-recruitly/HrFlow/backend/services/llm_service.py index f685cb9..8826c1b 100644 --- a/2synthesize-recruitly/HrFlow/backend/services/llm_service.py +++ b/2synthesize-recruitly/HrFlow/backend/services/llm_service.py @@ -1,12 +1,11 @@ """ -LLM service — supports 4 providers via LLM_PROVIDER env var: +LLM service — supports 3 providers via LLM_PROVIDER env var: - LLM_PROVIDER=anthropic → Claude API - LLM_PROVIDER=xai → xAI Grok API (OpenAI-compatible) ← current - LLM_PROVIDER=groq → Groq cloud (free tier, fast) + LLM_PROVIDER=anthropic → Claude API (default, paid) + LLM_PROVIDER=groq → Groq cloud (free tier, fast) ← recommended for hackathon LLM_PROVIDER=ollama → Ollama local (100% free, needs local model) -xAI, Groq and Ollama all expose an OpenAI-compatible API, so we use the openai +Groq and Ollama both expose an OpenAI-compatible API, so we use the openai package for them. Anthropic keeps its own client. """ @@ -18,7 +17,7 @@ # Provider config # --------------------------------------------------------------------------- -LLM_PROVIDER = os.getenv("LLM_PROVIDER", "xai").lower() +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "anthropic").lower() # --- Anthropic --- if LLM_PROVIDER == "anthropic": @@ -28,15 +27,6 @@ ) MODEL = os.getenv("LLM_MODEL", "claude-sonnet-4-6") -# --- xAI Grok (OpenAI-compatible) --- -elif LLM_PROVIDER == "xai": - from openai import AsyncOpenAI - _openai_client = AsyncOpenAI( - base_url="https://api.x.ai/v1", - api_key=os.getenv("XAI_API_KEY", ""), - ) - MODEL = os.getenv("LLM_MODEL", "grok-3-mini") - # --- Groq (OpenAI-compatible) --- elif LLM_PROVIDER == "groq": from openai import AsyncOpenAI @@ -158,6 +148,8 @@ async def extract_interview_signals(review_text: str) -> Dict[str, Any]: - decision must be one of: Hire, Consider, No Hire - confidence_level must be one of: High, Medium, Low - overall_score must be a float between 0.0 and 1.0 +- domain_fit: 2-3 sentences describing in which specific job domains/roles this candidate would excel, \ +which skills they can apply immediately, and the recommended role type. - Return ONLY valid JSON with the exact keys shown below, nothing else. Expected JSON: @@ -172,13 +164,73 @@ async def extract_interview_signals(review_text: str) -> Dict[str, Any]: "technical_assessment": "...", "behavioral_assessment": "...", "consistency_analysis": "...", - "justification": "..." + "justification": "...", + "domain_fit": "Strong fit for Backend Development and DevOps roles. Can contribute immediately on CI/CD pipelines and API development. Recommended entry point: Junior Backend Engineer." +} +""" + +# --------------------------------------------------------------------------- +# Agent 0 — Test Sheet Parsing Agent +# --------------------------------------------------------------------------- + +TEST_PARSE_SYSTEM_PROMPT = """\ +You are an AI technical test evaluator. + +You receive the text content of a technical test sheet or evaluation form. +Your task is to extract the competencies that were evaluated and normalize the scores to a 1-5 scale. + +Rules: +- Extract all evaluated competencies and their scores. +- Normalize scores to a 1-5 integer scale (1=weak, 2=insufficient, 3=acceptable, 4=good, 5=excellent). +- Group competency keys as: "technical.", "soft.", or "motivation.". +- target_skills: list of clean skill names (no prefix) found in the test. +- Return ONLY valid JSON with the exact keys shown below, nothing else. + +Expected JSON: +{ + "scores": { + "technical.python": 4, + "soft.communication": 3, + "motivation.role_interest": 5 + }, + "target_skills": ["Python", "Communication", "Role interest"] } """ +async def parse_test_sheet(file_text: str) -> Dict[str, Any]: + """Call the configured LLM to extract structured scores from a test sheet.""" + raw = await _chat(TEST_PARSE_SYSTEM_PROMPT, file_text) + return json.loads(_strip_fences(raw)) + + +# --------------------------------------------------------------------------- +# Agent 0b — Candidate Name Extractor (bypasses HrFlow name parsing bugs) +# --------------------------------------------------------------------------- + +NAME_EXTRACT_PROMPT = """\ +You are a CV parser. Extract only the candidate's full name from the CV text below. + +Rules: +- Return ONLY the full name as plain text (e.g. "Nabil Marc Chartouni"). +- Correct capitalisation (First Last format). +- Do NOT return JSON, labels, or any other text — just the name. +- If you cannot determine the name, return an empty string. +""" + + +async def extract_candidate_name(cv_text: str) -> str: + """Use the LLM to extract the candidate's full name from raw CV text.""" + # Only send the first 800 chars — the name is always near the top + snippet = cv_text[:800].strip() + if not snippet: + return "" + raw = await _chat(NAME_EXTRACT_PROMPT, snippet) + return raw.strip() + + async def generate_synthesis(assessment_object: Dict[str, Any]) -> Dict[str, Any]: """Call the configured LLM to generate the final candidate synthesis report.""" payload = json.dumps(assessment_object, indent=2, ensure_ascii=False) raw = await _chat(SYNTHESIS_SYSTEM_PROMPT, payload) - return json.loads(_strip_fences(raw)) \ No newline at end of file + return json.loads(_strip_fences(raw)) diff --git a/2synthesize-recruitly/HrFlow/frontend/src/App.tsx b/2synthesize-recruitly/HrFlow/frontend/src/App.tsx index b181a5b..0b2b7a1 100644 --- a/2synthesize-recruitly/HrFlow/frontend/src/App.tsx +++ b/2synthesize-recruitly/HrFlow/frontend/src/App.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react' -import { FormValues, PipelineResult } from './types' +import { useState, useEffect } from 'react' +import { FormValues, JobOption, PipelineResult } from './types' import InputPage from './pages/InputPage' import ProcessingPage from './pages/ProcessingPage' import ResultsPage from './pages/ResultsPage' import { runFullPipeline } from './api/pipeline' +import { fetchJobs } from './api/jobs' type View = 'input' | 'processing' | 'results' @@ -11,6 +12,11 @@ export default function App() { const [view, setView] = useState('input') const [result, setResult] = useState(null) const [error, setError] = useState(null) + const [jobs, setJobs] = useState([]) + + useEffect(() => { + fetchJobs().then(setJobs) + }, []) async function handleSubmit(form: FormValues) { setError(null) @@ -93,7 +99,7 @@ export default function App() { )} - {view === 'input' && } + {view === 'input' && } {view === 'processing' && } {view === 'results' && result && ( diff --git a/2synthesize-recruitly/HrFlow/frontend/src/api/jobs.ts b/2synthesize-recruitly/HrFlow/frontend/src/api/jobs.ts new file mode 100644 index 0000000..b5d3d67 --- /dev/null +++ b/2synthesize-recruitly/HrFlow/frontend/src/api/jobs.ts @@ -0,0 +1,8 @@ +import { JobOption } from '../types' + +export async function fetchJobs(): Promise { + const res = await fetch('/api/jobs/list') + if (!res.ok) return [] + const data = await res.json() + return (data.jobs || []) as JobOption[] +} diff --git a/2synthesize-recruitly/HrFlow/frontend/src/pages/InputPage.tsx b/2synthesize-recruitly/HrFlow/frontend/src/pages/InputPage.tsx index c04e093..db86502 100644 --- a/2synthesize-recruitly/HrFlow/frontend/src/pages/InputPage.tsx +++ b/2synthesize-recruitly/HrFlow/frontend/src/pages/InputPage.tsx @@ -1,17 +1,5 @@ -import { useState, useRef, DragEvent, ChangeEvent } from 'react' -import { FormValues, InterviewType } from '../types' - -const DEFAULT_TEST_JSON = JSON.stringify( - { - 'technical.python': 4, - 'technical.sql': 3, - 'technical.system_design': 2, - 'soft.communication': 4, - 'motivation.role_interest': 5, - }, - null, - 2, -) +import { useState, useRef, useEffect, RefObject, DragEvent, ChangeEvent } from 'react' +import { FormValues, InterviewType, JobOption } from '../types' const INTERVIEW_TYPES: { value: InterviewType; label: string }[] = [ { value: 'technical_interview', label: 'Technical Interview' }, @@ -20,11 +8,17 @@ const INTERVIEW_TYPES: { value: InterviewType; label: string }[] = [ { value: 'assessment_review', label: 'Assessment Review' }, ] +interface ParsedTest { + scores: Record + target_skills: string[] +} + interface Props { onSubmit: (form: FormValues) => void + jobs: JobOption[] } -export default function InputPage({ onSubmit }: Props) { +export default function InputPage({ onSubmit, jobs }: Props) { const [form, setForm] = useState({ candidateName: '', candidateId: '', @@ -34,38 +28,136 @@ export default function InputPage({ onSubmit }: Props) { sourceKey: '', targetSkills: '', cvFile: null, - testResultsJson: DEFAULT_TEST_JSON, + testFile: null, + testResultsJson: '', interviewType: 'technical_interview', reviewText: '', }) - const [dragging, setDragging] = useState(false) - const [jsonError, setJsonError] = useState(null) + const [cvDragging, setCvDragging] = useState(false) + const [cvParsing, setCvParsing] = useState(false) + const [cvParsed, setCvParsed] = useState(false) + const [cvParseError, setCvParseError] = useState(null) + const [testDragging, setTestDragging] = useState(false) + const [testParsed, setTestParsed] = useState(null) + const [testParsing, setTestParsing] = useState(false) + const [testParseError, setTestParseError] = useState(null) const [showAdvanced, setShowAdvanced] = useState(false) - const fileInputRef = useRef(null) + const [jobQuery, setJobQuery] = useState('') + const [jobDropdownOpen, setJobDropdownOpen] = useState(false) + const [selectedJob, setSelectedJob] = useState(null) + const cvInputRef = useRef(null) + const testInputRef = useRef(null) + const jobInputRef = useRef(null) + + const filteredJobs = jobs.filter(j => + j.title.toLowerCase().includes(jobQuery.toLowerCase()) + ) + + function handleJobSelect(job: JobOption) { + setSelectedJob(job) + setJobQuery(job.title) + setJobDropdownOpen(false) + setForm(f => ({ ...f, jobTitle: job.title, jobId: job.key })) + } + + function handleJobQueryChange(val: string) { + setJobQuery(val) + setJobDropdownOpen(true) + setSelectedJob(null) + setForm(f => ({ ...f, jobTitle: val, jobId: '' })) + } + + // Close dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (jobInputRef.current && !jobInputRef.current.closest('.job-autocomplete')?.contains(e.target as Node)) { + setJobDropdownOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) function set(key: keyof FormValues, value: string | File | null) { setForm((f) => ({ ...f, [key]: value })) } - function handleFileAccept(file: File) { + async function handleTestFileAccept(file: File) { + set('testFile', file) + setTestParsed(null) + setTestParseError(null) + setTestParsing(true) + + try { + const fd = new FormData() + fd.append('file', file) + const res = await fetch('/api/test/parse', { method: 'POST', body: fd }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || 'Parse failed') + } + const data: ParsedTest = await res.json() + setTestParsed(data) + setForm((f) => ({ + ...f, + testFile: file, + testResultsJson: JSON.stringify(data.scores), + targetSkills: data.target_skills.join(', '), + })) + } catch (e: any) { + setTestParseError(e.message || 'Could not parse test sheet') + } finally { + setTestParsing(false) + } + } + + async function handleCvFileAccept(file: File) { set('cvFile', file) + setCvParsed(false) + setCvParseError(null) + setCvParsing(true) + + try { + const fd = new FormData() + fd.append('file', file) + // source_key optional — backend falls back to env HRFLOW_SOURCE_KEY + const res = await fetch('/api/cv/parse', { method: 'POST', body: fd }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || 'CV parse failed') + } + const data = await res.json() + setCvParsed(true) + setForm((f) => ({ + ...f, + cvFile: file, + // Only auto-fill if the user hasn't typed a name already + candidateName: f.candidateName.trim() === '' ? (data.full_name || '') : f.candidateName, + // Auto-generate ID from profile_key (short prefix) if not filled + candidateId: f.candidateId.trim() === '' + ? (data.profile_key ? `cand_${data.profile_key.slice(0, 8)}` : `cand_${Date.now()}`) + : f.candidateId, + })) + } catch (e: any) { + setCvParseError(e.message || 'Could not parse CV') + setCvParsed(false) + } finally { + setCvParsing(false) + } } - function handleDrop(e: DragEvent) { + function handleCvDrop(e: DragEvent) { e.preventDefault() - setDragging(false) + setCvDragging(false) const file = e.dataTransfer.files[0] - if (file) handleFileAccept(file) + if (file) handleCvFileAccept(file) } - function handleJsonChange(val: string) { - set('testResultsJson', val) - try { - JSON.parse(val) - setJsonError(null) - } catch { - setJsonError('Invalid JSON') - } + function handleTestDrop(e: DragEvent) { + e.preventDefault() + setTestDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleTestFileAccept(file) } function handleSubmit(e: React.FormEvent) { @@ -78,7 +170,7 @@ export default function InputPage({ onSubmit }: Props) { form.jobTitle.trim() !== '' && form.testResultsJson.trim() !== '' && form.reviewText.trim() !== '' && - jsonError === null + !testParsing return (
@@ -92,98 +184,173 @@ export default function InputPage({ onSubmit }: Props) {

Candidate

- - set('candidateName', e.target.value)} /> + + set('candidateName', e.target.value)} + />
- - set('candidateId', e.target.value)} /> + + set('candidateId', e.target.value)} + />
+ {cvParseError && ( +

⚠ Could not auto-fill: {cvParseError}. Fill manually.

+ )}
- {/* Job */} + {/* Job — autocomplete */}

Target job

-
-
- - set('jobTitle', e.target.value)} /> -
-
- - set('targetSkills', e.target.value)} /> -
+
+ + handleJobQueryChange(e.target.value)} + onFocus={() => setJobDropdownOpen(true)} + autoComplete="off" + /> + {/* Dropdown */} + {jobDropdownOpen && filteredJobs.length > 0 && ( +
    + {filteredJobs.map(job => ( +
  • handleJobSelect(job)} + > + {job.title} + {job.skills.length > 0 && ( + + {job.skills.slice(0, 5).join(' · ')} + + )} +
  • + ))} +
+ )} + {jobDropdownOpen && jobQuery.length > 0 && filteredJobs.length === 0 && ( +
+ No matching job found +
+ )}
+ + {/* Required skills hint */} + {selectedJob && selectedJob.skills.length > 0 && ( +
+

+ Required skills for this position + — your test sheet should cover these +

+
+ {selectedJob.skills.map((skill, i) => ( + + {skill} + + ))} +
+
+ )}
{/* CV Upload */}

Resume / CV *

-
{ e.preventDefault(); setDragging(true) }} - onDragLeave={() => setDragging(false)} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - > - ) => { - const f = e.target.files?.[0] - if (f) handleFileAccept(f) - }} /> - {form.cvFile ? ( -
-
- - - -
-
-

{form.cvFile.name}

-

{(form.cvFile.size / 1024).toFixed(0)} KB — click to change

-
-
- ) : ( - <> - - - -

Drop your CV here or click to browse

-

PDF, DOCX, DOC — max 10 MB

- - )} -
+ setCvDragging(true)} + onDragLeave={() => setCvDragging(false)} + onDrop={handleCvDrop} + onFileChange={handleCvFileAccept} + loading={cvParsing} + />
{/* Right column */}
- {/* Test results */} + {/* Test sheet upload */}
-
-

Test results *

- Keys: category.skill, scores 1–5 -
-