diff --git a/HEpiR-HREvolution/.env.example b/HEpiR-HREvolution/.env.example new file mode 100644 index 0000000..8cc06fb --- /dev/null +++ b/HEpiR-HREvolution/.env.example @@ -0,0 +1,9 @@ +HRFLOW_API_KEY=your_hrflow_api_key +HRFLOW_USER_EMAIL=your_hrflow_user_email +HRFLOW_SOURCE_KEY=your_hrflow_source_key +HRFLOW_BOARD_KEY=your_hrflow_board_key + +# OpenRouter (https://openrouter.ai) — or any OpenAI-compatible LLM API +LLM_API_KEY=your_openrouter_api_key +LLM_BASE_URL=https://openrouter.ai/api/v1 +LLM_MODEL=nvidia/nemotron-3-super-120b-a12b:free diff --git a/HEpiR-HREvolution/PROJECT_DOCS.md b/HEpiR-HREvolution/PROJECT_DOCS.md new file mode 100644 index 0000000..6196dfd --- /dev/null +++ b/HEpiR-HREvolution/PROJECT_DOCS.md @@ -0,0 +1,125 @@ +# HRFlow Hackathon — Project Documentation + +## Overview + +**Project Name:** Candidate Application Synthesis Generator +**Goal:** AI-powered recruitment pipeline dashboard. Integrates the HRFlow API to track, rank, and synthesise candidate applications per job. An LLM layer grades candidates, analyses their profiles, and auto-generates structured recruitment summaries. + +**UI Design:** Slack-dark sidebar + Jira-light content. Minimalist, information-dense, modern. + +--- + +## Detailed Documentation + +| Document | Contents | +|----------|----------| +| [docs/architecture.md](docs/architecture.md) | System design, file structure, data persistence strategy, indexing delay mitigations | +| [docs/api-reference.md](docs/api-reference.md) | All backend API endpoints with request/response schemas | +| [docs/hrflow-integration.md](docs/hrflow-integration.md) | HRFlow endpoints used, data models, tag schemas, known quirks & fixes | +| [docs/ai-pipeline.md](docs/ai-pipeline.md) | Grading pipeline, synthesis flow, caching, prompt rules, robustness notes | +| [docs/frontend-components.md](docs/frontend-components.md) | Component breakdown, layout, props, UX flows, api.js reference | +| [docs/infrastructure.md](docs/infrastructure.md) | Docker Compose, env vars, Dockerfiles, FastAPI config, running the stack | +| [docs/candidate-extra-documents.md](docs/candidate-extra-documents.md) | Extra document data model, file upload/extraction, audio transcription, scoring integration, UI | +| [docs/manual-status-management.md](docs/manual-status-management.md) | Candidate stage pipeline, bonus scoring, real-time UI updates | + +--- + +## Tech Stack + +| Layer | Technology | +|----------|------------| +| Frontend | React 18, Vite 5 | +| Backend | Python 3.12, FastAPI | +| AI (grading/synthesis/questions) | OpenRouter — configurable via `LLM_MODEL` env var | +| AI (audio transcription) | OpenRouter — `google/gemini-2.0-flash-001` (fixed) | +| PDF extraction | `pypdf` | +| DOCX extraction | `python-docx` | +| HR Data | HRFlow API v1 | +| Infra | Docker Compose | + +--- + +## Quick Start + +```bash +# 1. Copy and fill in credentials +cp .env.example .env + +# 2. Start the full stack +sudo docker compose up --build + +# Frontend → http://localhost:3000 +# Swagger → http://localhost:8080/docs +``` + +**.env required keys:** +```env +HRFLOW_API_KEY=... +HRFLOW_USER_EMAIL=... +HRFLOW_SOURCE_KEY=... +HRFLOW_BOARD_KEY=... +LLM_API_KEY=... +LLM_BASE_URL=https://openrouter.ai/api/v1 +LLM_MODEL=nvidia/nemotron-3-super-120b-a12b:free +``` + +--- + +## Data Flow + +``` +PDF Resume Upload (job-scoped) + → HRFlow profile/parsing/file → profile created in Source + → HRFlow tracking/indexing → links profile to job (stage: applied) + → localStorage pending key → shown immediately before indexing completes + → POST /api/ai/grade → auto-triggered + → HRFlow base score + upskilling + → LLM → final_score + rationale + → PUT profile tag: job_data_{job_key} + → POST /api/ai/synthesize → auto-triggered after grade + → LLM → synthesis (summary, strengths, weaknesses, verdict) + → PUT profile tag: synthesis_{job_key} + → in-memory cache: _synthesis_cache[job_key:profile_key] + +Extra Document Submission (text) + → POST /api/candidates/{key}/documents + → POST /api/ai/grade → auto-triggered; only unscored docs re-evaluated + → POST /api/ai/synthesize → auto-triggered; synthesis refreshed + +Extra Document Upload (PDF / DOCX / audio) + → POST /api/candidates/{key}/documents/file + PDF → pypdf text extraction + DOCX → python-docx paragraph extraction + Audio → LLM transcription (google/gemini-2.0-flash-001) + → extracted text stored as document content + → POST /api/ai/grade + synthesize → same auto chain as text submission + +Job Creation + → HRFlow job/indexing → job created in Board + → localStorage pending key → shown immediately before indexing completes + +Candidate Panel Open + → GET /api/candidates/{key} → full profile (parallel) + → GET /api/ai/synthesis → cache hit → instant display + cache miss → HRFlow tag → display + tag miss → auto-generate → store → display +``` + +--- + +## Key Design Decisions + +| Decision | Reason | +|----------|--------| +| No local database | HRFlow is the single source of truth; avoids sync complexity | +| `PUT` (not `PATCH`) for profile tags | HRFlow returns 405 on PATCH; PUT requires full profile payload | +| In-memory synthesis cache | HRFlow tag indexing delay means tags aren't readable immediately after write | +| localStorage pending keys (jobs + candidates) | HRFlow search index lags after creation; individual GET fallback bridges the gap | +| `redirect_slashes=False` on FastAPI | Prevents 307 redirects that break the Vite `/api` proxy | +| Auto-grade + auto-synthesise on upload | Removes manual steps; synthesis is ready when the panel is first opened | +| Extra candidate skills = neutral/positive | LLM prompt explicitly forbids penalising over-qualification | +| Tracking created on upload | Without a tracking, candidates never appear in job's candidate list | +| File extraction server-side | PDF/DOCX parsing and audio transcription happen in the backend; frontend receives only text | +| Race condition guard on job switching | `currentJobKeyRef` pattern discards stale candidate-list responses when the user navigates quickly | +| Optimistic stage/bonus update via `candidateOverride` | Stage and bonus changes are reflected immediately in the candidate list without waiting for HRFlow re-fetch | +| Synthesis banner gated on profile load | Banner is suppressed until profile data has arrived, preventing false "Generating synthesis…" on initial open | diff --git a/HEpiR-HREvolution/README.md b/HEpiR-HREvolution/README.md new file mode 100644 index 0000000..c2875eb --- /dev/null +++ b/HEpiR-HREvolution/README.md @@ -0,0 +1,89 @@ +# HEpiR — HR Evolution + +> AI-powered recruitment dashboard that ranks, analyses, and synthesises candidate applications in real time. + +## What it does + +HEpiR connects to the HrFlow.ai API to give HR teams a unified view of every job opening and its applicants. Drop a PDF resume into a job, and the system instantly scores the candidate against the role, generates a structured AI synthesis (strengths, weaknesses, upskilling recommendations), and lets HR attach supplementary documents — interview notes, technical test transcripts, audio recordings — that feed directly back into the scoring model. + +Key capabilities: +- **Ranked candidate list** per job, scored by HrFlow's native matching engine combined with an LLM adjustment layer +- **AI synthesis** — structured summary, strengths, weaknesses, upskilling recommendations, and a hire verdict, auto-generated on upload and refreshable on demand +- **Extra documents** — attach plain text, PDF, DOCX, or audio files to any candidate; each document is individually scored by the LLM and contributes a delta to the total score +- **Interview question generator** — tailored questions based on the candidate's profile and attached documents +- **Recruitment pipeline** — customisable stages per job (Screening, Interview, Technical Test, …) with real-time stage tracking +- **HR bonus** — manual score adjustment (±) on top of the AI score +- **Job management** — create jobs, set operational status (Open / On Hold / Closed), manage custom pipeline stages + +## HrFlow.ai APIs used + +| Endpoint | Usage | +|----------|-------| +| `POST /v1/profile/parsing/file` | Parse a PDF resume and create a candidate profile | +| `GET /v1/profile/indexing` | Fetch a full candidate profile (skills, experiences, tags, metadata) | +| `PUT /v1/profile/indexing` | Store scores, synthesis, stage, and extra documents in profile tags/metadata | +| `POST /v1/tracking/indexing` | Link a candidate profile to a job (creates the application) | +| `GET /v1/tracking/searching` | List all candidates who applied to a given job | +| `GET /v1/job/indexing` | Fetch a single job's full data | +| `POST /v1/job/indexing` | Create a new job in the board | +| `GET /v1/job/searching` | List all jobs in the board | +| `POST /v1/score/searching` | Compute HrFlow's native matching score between a profile and a job | + +## How to run + +### Prerequisites + +- Docker & Docker Compose +- An [HrFlow.ai](https://hrflow.ai) account with an API key, source key, and board key +- An [OpenRouter](https://openrouter.ai) API key (or any OpenAI-compatible LLM endpoint) + +### Setup + +```bash +# Clone the repo +git clone +cd HEpiR-HREvolution + +# Copy and fill in credentials +cp .env.example .env +# Edit .env with your actual keys + +# Build and start the full stack +docker compose up --build +``` + +- **Frontend** → http://localhost:3000 +- **API / Swagger UI** → http://localhost:8080/docs + +### Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `HRFLOW_API_KEY` | Yes | HrFlow.ai API secret key | +| `HRFLOW_USER_EMAIL` | Yes | HrFlow.ai account email | +| `HRFLOW_SOURCE_KEY` | Yes | HrFlow.ai source key (profile storage) | +| `HRFLOW_BOARD_KEY` | Yes | HrFlow.ai board key (job storage) | +| `LLM_API_KEY` | Yes | OpenRouter (or compatible) API key | +| `LLM_BASE_URL` | Yes | LLM base URL (default: `https://openrouter.ai/api/v1`) | +| `LLM_MODEL` | Yes | Model for grading/synthesis (e.g. `nvidia/nemotron-super-49b-v1:free`) | + +## Architecture + +``` +frontend/ React 18 + Vite — dashboard UI +backend/ Python 3.12 + FastAPI — orchestration layer + ├── routers/jobs.py job CRUD + stage pipeline + ├── routers/candidates.py profile, score, documents, file upload + ├── routers/ai.py grading, synthesis, interview questions + └── services/ + ├── hrflow.py HrFlow API client + └── llm.py OpenRouter LLM calls +``` + +No local database — HrFlow is the single source of truth. Scores, synthesis, and extra documents are stored directly in profile tags and metadata. + +## Team + +- **Adrien CAPITAINE** — Developer +- **Nathan CHAMPAGNE** — Developer +- **Joris BELY** — Developer \ No newline at end of file diff --git a/HEpiR-HREvolution/app.json b/HEpiR-HREvolution/app.json new file mode 100644 index 0000000..c380d7b --- /dev/null +++ b/HEpiR-HREvolution/app.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../schemas/app.schema.json", + "name": "HEpiR — HR Evolution", + "description": "AI-powered recruitment dashboard. Ranks candidates per job using HrFlow scoring combined with an LLM adjustment layer, auto-generates structured synthesis reports, and lets HR attach documents (PDF, DOCX, audio) that feed back into the score.", + "credentials": { + "source_keys": [], + "board_keys": [], + "algorithm_key": "" + }, + "settings": { + "team_name": "HEpiR", + "theme_color": "#1264a3", + "custom_filters": [], + "filters": [] + } +} diff --git a/HEpiR-HREvolution/assets/preview.png b/HEpiR-HREvolution/assets/preview.png new file mode 100644 index 0000000..ad67add Binary files /dev/null and b/HEpiR-HREvolution/assets/preview.png differ diff --git a/HEpiR-HREvolution/backend/Dockerfile b/HEpiR-HREvolution/backend/Dockerfile new file mode 100644 index 0000000..963a174 --- /dev/null +++ b/HEpiR-HREvolution/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8080 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] diff --git a/HEpiR-HREvolution/backend/config.py b/HEpiR-HREvolution/backend/config.py new file mode 100644 index 0000000..374adf0 --- /dev/null +++ b/HEpiR-HREvolution/backend/config.py @@ -0,0 +1,19 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + hrflow_api_key: str + hrflow_user_email: str + hrflow_source_key: str + hrflow_board_key: str + + llm_api_key: str + llm_base_url: str = "https://openrouter.ai/api/v1" + llm_model: str = "nvidia/nemotron-3-super-120b-a12b:free" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/HEpiR-HREvolution/backend/main.py b/HEpiR-HREvolution/backend/main.py new file mode 100644 index 0000000..484026d --- /dev/null +++ b/HEpiR-HREvolution/backend/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from routers import jobs, candidates, ai + +app = FastAPI(title="HRFlow Hackathon API", version="1.0.0", redirect_slashes=False) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(jobs.router, prefix="/api/jobs", tags=["jobs"]) +app.include_router(candidates.router, prefix="/api/candidates", tags=["candidates"]) +app.include_router(ai.router, prefix="/api/ai", tags=["ai"]) + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/HEpiR-HREvolution/backend/requirements.txt b/HEpiR-HREvolution/backend/requirements.txt new file mode 100644 index 0000000..47559ee --- /dev/null +++ b/HEpiR-HREvolution/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +python-dotenv==1.0.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +openai==1.51.0 +python-multipart==0.0.12 +pypdf==5.1.0 +python-docx==1.1.2 diff --git a/HEpiR-HREvolution/backend/routers/__init__.py b/HEpiR-HREvolution/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/HEpiR-HREvolution/backend/routers/ai.py b/HEpiR-HREvolution/backend/routers/ai.py new file mode 100644 index 0000000..7b4f885 --- /dev/null +++ b/HEpiR-HREvolution/backend/routers/ai.py @@ -0,0 +1,158 @@ +"""AI router — grading, synthesis, and interview question generation.""" + +import json +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services import hrflow, llm + +router = APIRouter() + + +class GradeRequest(BaseModel): + job_key: str + profile_key: str + + +class SynthesizeRequest(BaseModel): + job_key: str + profile_key: str + + +class AskRequest(BaseModel): + job_key: str + profile_key: str + + +@router.post("/grade") +async def grade_candidate(req: GradeRequest): + """ + Full grading pipeline: + 1. Fetch HRFlow base score + upskilling data — written to profile tag immediately + 2. LLM produces final adjusted score — stored in profile tag + 3. LLM generates synthesis — stored in profile tag + """ + try: + job = await hrflow.get_job(req.job_key) + profile = await hrflow.get_profile(req.profile_key) + + existing_tag = hrflow.extract_tag(profile, f"job_data_{req.job_key}") + existing = json.loads(existing_tag) if existing_tag else {} + extra_docs = hrflow.get_extra_documents(profile, req.job_key) + + # Re-use cached base_score — HRFlow algorithmic score only changes when the profile + # itself changes, not when documents or bonuses are updated. + cached_base = existing.get("base_score") + if cached_base is not None: + base_score = cached_base + print(f"[grade] base_score={base_score} (cached, skipping HRFlow API call)", flush=True) + else: + base_score = await hrflow.get_profile_score(req.job_key, req.profile_key) or 0.0 + # Write base_score immediately — visible even if subsequent LLM call fails + await _patch_tag(req.profile_key, profile, f"job_data_{req.job_key}", json.dumps({ + **existing, + "job_key": req.job_key, + "base_score": base_score, + })) + print(f"[grade] base_score={base_score} fetched from HRFlow", flush=True) + + # Score only documents that don't have a stored delta yet — existing deltas are stable + if extra_docs: + already_scored = [d for d in extra_docs if d.get("delta") is not None] + to_score = [d for d in extra_docs if d.get("delta") is None] + newly_scored = [] + for doc in to_score: + other_docs = [d for d in extra_docs if d["id"] != doc["id"]] + score_result = await llm.score_single_document(job, profile, doc, other_docs) + newly_scored.append({**doc, "delta": score_result["delta"], "rationale": score_result["rationale"]}) + print(f"[grade] new doc '{doc.get('filename')}' delta={score_result['delta']} → {score_result['rationale']}", flush=True) + if newly_scored: + await hrflow.update_documents_with_deltas(req.profile_key, req.job_key, newly_scored) + all_deltas = [d["delta"] for d in already_scored] + [d["delta"] for d in newly_scored] + ai_adjustment = round(max(-0.3, min(0.3, sum(all_deltas))), 3) + else: + ai_adjustment = 0.0 + print(f"[grade] total ai_adjustment={ai_adjustment} ({len(already_scored) if extra_docs else 0} cached, {len(newly_scored) if extra_docs else 0} new)", flush=True) + + # Persist updated scores — return immediately so the frontend can update the display + # Synthesis is triggered separately by the frontend after this response + profile = await hrflow.get_profile(req.profile_key) + await _patch_tag(req.profile_key, profile, f"job_data_{req.job_key}", json.dumps({ + "job_key": req.job_key, + "base_score": base_score, + "ai_adjustment": ai_adjustment, + "bonus": existing.get("bonus", 0.0), + })) + + return { + "base_score": base_score, + "ai_adjustment": ai_adjustment, + } + except Exception as e: + print(f"grade error: {e}", flush=True) + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/synthesis") +async def get_synthesis(job_key: str, profile_key: str): + """Return the stored synthesis for a candidate, or null if not yet generated.""" + try: + profile = await hrflow.get_profile(profile_key) + raw = hrflow.extract_tag(profile, f"synthesis_{job_key}") + return json.loads(raw) if raw else None + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/synthesize") +async def synthesize_candidate(req: SynthesizeRequest): + """Manually (re-)generate and store a synthesis for a candidate.""" + try: + job, profile, tracking = await _fetch_context(req.job_key, req.profile_key) + + try: + upskilling = await hrflow.get_job_upskilling(req.job_key, req.profile_key) + except Exception as ue: + print(f"upskilling fetch failed (non-fatal): {ue}", flush=True) + upskilling = {} + + raw_tag = hrflow.extract_tag(profile, f"job_data_{req.job_key}") + final_score = json.loads(raw_tag).get("score", 0.5) if raw_tag else 0.5 + extra_docs = hrflow.get_extra_documents(profile, req.job_key) + + synthesis = await llm.synthesize_candidate( + job, profile, tracking or {}, upskilling, final_score, extra_docs + ) + await _patch_tag(req.profile_key, profile, f"synthesis_{req.job_key}", json.dumps(synthesis)) + return synthesis + except Exception as e: + print(f"synthesize error: {e}", flush=True) + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/ask") +async def ask_questions(req: AskRequest): + """Generate tailored interview questions for a candidate.""" + try: + job, profile, _ = await _fetch_context(req.job_key, req.profile_key) + extra_docs = hrflow.get_extra_documents(profile, req.job_key) + questions = await llm.generate_questions(job, profile, extra_docs) + return questions + except Exception as e: + print(f"ask error: {e}", flush=True) + raise HTTPException(status_code=502, detail=str(e)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +async def _fetch_context(job_key: str, profile_key: str): + job = await hrflow.get_job(job_key) + profile = await hrflow.get_profile(profile_key) + tracking = await hrflow.get_tracking(job_key, profile_key) + return job, profile, tracking + + +async def _patch_tag(profile_key: str, profile: dict, tag_name: str, tag_value: str): + existing = [t for t in profile.get("tags", []) if t.get("name") != tag_name] + await hrflow.patch_profile_tags(profile_key, existing + [{"name": tag_name, "value": tag_value}]) diff --git a/HEpiR-HREvolution/backend/routers/candidates.py b/HEpiR-HREvolution/backend/routers/candidates.py new file mode 100644 index 0000000..4b914c6 --- /dev/null +++ b/HEpiR-HREvolution/backend/routers/candidates.py @@ -0,0 +1,196 @@ +"""Candidates router — profile data, score management, and resume upload.""" + +import io +import json +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel +from services import hrflow, llm +from pypdf import PdfReader +from docx import Document as DocxDocument + +router = APIRouter() + + +class ScorePayload(BaseModel): + job_key: str + score: float + bonus: float = 0.0 + + +class DocumentPayload(BaseModel): + job_key: str + filename: str = "" + content: str + + +class BonusPayload(BaseModel): + job_key: str + bonus: float + + +class StagePayload(BaseModel): + job_key: str + stage: str + + +@router.patch("/{profile_key}/stage") +async def update_candidate_stage(profile_key: str, payload: StagePayload): + """Update candidate recruitment stage for a specific job.""" + try: + result = await hrflow.update_candidate_stage(profile_key, payload.job_key, payload.stage) + return {"ok": True, "profile_key": profile_key, "job_key": payload.job_key, **result} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/upload") +async def upload_resume(file: UploadFile = File(...), job_key: str = Form(None)): + """Parse a PDF resume, create a candidate profile, and optionally link it to a job.""" + if not file.filename.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="Only PDF files are accepted.") + try: + content = await file.read() + result = await hrflow.parse_resume_file(content, file.filename) + profile = result.get("profile", result) + profile_key = profile.get("key") + info = profile.get("info", {}) + + if job_key and profile_key: + try: + await hrflow.create_tracking(job_key, profile_key) + except Exception as te: + print(f"create_tracking failed (non-fatal): {te}", flush=True) + + return { + "ok": True, + "profile_key": profile_key, + "name": f"{info.get('first_name', '')} {info.get('last_name', '')}".strip(), + "email": info.get("email", ""), + } + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{profile_key}") +async def get_candidate(profile_key: str): + """Get full profile for a candidate.""" + try: + profile = await hrflow.get_profile(profile_key) + return profile + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{profile_key}/score") +async def get_candidate_score(profile_key: str, job_key: str): + """Read the stored score for a candidate on a specific job from profile tags.""" + try: + profile = await hrflow.get_profile(profile_key) + raw_tag = hrflow.extract_tag(profile, f"job_data_{job_key}") + if raw_tag: + return json.loads(raw_tag) + return {"job_key": job_key, "score": None, "bonus": 0.0} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{profile_key}/score") +async def store_candidate_score(profile_key: str, payload: ScorePayload): + """Store the computed score in the candidate's profile tags.""" + try: + profile = await hrflow.get_profile(profile_key) + existing_tags = [ + t for t in profile.get("tags", []) + if t.get("name") != f"job_data_{payload.job_key}" + ] + new_tag = hrflow.build_job_tag(payload.job_key, payload.score, payload.bonus) + updated = await hrflow.patch_profile_tags(profile_key, existing_tags + [new_tag]) + return {"ok": True, "tags": updated.get("tags", [])} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.patch("/{profile_key}/bonus") +async def update_bonus(profile_key: str, payload: BonusPayload): + """Let HR adjust the bonus points for a candidate on a specific job.""" + try: + profile = await hrflow.get_profile(profile_key) + raw_tag = hrflow.extract_tag(profile, f"job_data_{payload.job_key}") + tag_data = json.loads(raw_tag) if raw_tag else {} + + existing_tags = [t for t in profile.get("tags", []) if t.get("name") != f"job_data_{payload.job_key}"] + new_tag = {"name": f"job_data_{payload.job_key}", "value": json.dumps({ + "job_key": payload.job_key, + "base_score": tag_data.get("base_score"), + "ai_adjustment": tag_data.get("ai_adjustment", 0.0), + "bonus": payload.bonus, + })} + await hrflow.patch_profile_tags(profile_key, existing_tags + [new_tag]) + return {"ok": True, "bonus": payload.bonus} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{profile_key}/documents") +async def get_documents(profile_key: str, job_key: str): + """List extra HR documents attached to a candidate for a specific job.""" + try: + profile = await hrflow.get_profile(profile_key) + return {"documents": hrflow.get_extra_documents(profile, job_key)} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{profile_key}/documents") +async def add_document(profile_key: str, payload: DocumentPayload): + """Attach a new text document to a candidate's profile for a specific job.""" + try: + doc_id = await hrflow.add_extra_document( + profile_key, payload.job_key, payload.filename, payload.content + ) + return {"ok": True, "id": doc_id} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{profile_key}/documents/file") +async def add_document_file( + profile_key: str, + job_key: str = Form(...), + file: UploadFile = File(...) +): + """Upload a file (PDF, Docx, Audio), transcribe/extract text, and save as document.""" + filename = file.filename + ext = filename.lower().split(".")[-1] + + try: + content = await file.read() + extracted_text = "" + + if ext == "pdf": + reader = PdfReader(io.BytesIO(content)) + extracted_text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()]) + elif ext in ["docx", "doc"]: + # Need to install python-docx + doc = DocxDocument(io.BytesIO(content)) + extracted_text = "\n".join([p.text for p in doc.paragraphs]) + elif ext in ["mp3", "m4a", "wav", "aac", "ogg", "flac", "aiff"]: + extracted_text = await llm.transcribe_audio(content, filename) + else: + # Fallback for plain text files + try: + extracted_text = content.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(status_code=400, detail=f"Unsupported file format: {ext}") + + if not extracted_text or not extracted_text.strip(): + raise HTTPException(status_code=400, detail="No text could be extracted or transcribed from this file.") + + doc_id = await hrflow.add_extra_document( + profile_key, job_key, filename, extracted_text + ) + return {"ok": True, "id": doc_id, "content": extracted_text} + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=502, detail=str(e)) diff --git a/HEpiR-HREvolution/backend/routers/jobs.py b/HEpiR-HREvolution/backend/routers/jobs.py new file mode 100644 index 0000000..d129638 --- /dev/null +++ b/HEpiR-HREvolution/backend/routers/jobs.py @@ -0,0 +1,320 @@ +"""Jobs router — lists jobs, their ranked candidates, and job creation.""" + +import json +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services import hrflow + +router = APIRouter() + + +class SkillInput(BaseModel): + name: str + value: str = "intermediate" + + +class JobCreatePayload(BaseModel): + name: str + summary: str = "" + location: str = "" + skills: list[SkillInput] = [] + + +@router.post("") +async def create_job(payload: JobCreatePayload): + """Create a new job in the HRFlow board.""" + try: + skills = [ + {"name": sk.name.strip(), "type": "hard", "value": sk.value} + for sk in payload.skills + if sk.name.strip() + ] + job_body: dict = { + "name": payload.name, + "tags": [], + "metadatas": [], + "ranges_date": [], + "ranges_float": [], + } + if payload.summary: + job_body["summary"] = payload.summary + job_body["sections"] = [ + {"name": "description", "title": "Description", "description": payload.summary} + ] + if payload.location: + job_body["location"] = {"text": payload.location} + if skills: + job_body["skills"] = skills + + result = await hrflow.create_job(job_body) + return {"ok": True, "job_key": result.get("key"), "name": result.get("name")} + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=502, detail=e.response.text) + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/debug-raw") +async def debug_raw_jobs(): + """Return the raw HRFlow response for debugging.""" + from config import settings + import httpx as _httpx + async with _httpx.AsyncClient() as client: + r = await client.get( + "https://api.hrflow.ai/v1/jobs/searching", + headers={"X-API-KEY": settings.hrflow_api_key, "X-USER-EMAIL": settings.hrflow_user_email}, + params={"board_keys": f'["{settings.hrflow_board_key}"]', "limit": 5}, + timeout=15, + ) + return r.json() + + +@router.get("") +async def get_jobs(): + """List all jobs from the configured HRFlow board.""" + try: + jobs = await hrflow.list_jobs() + return {"jobs": jobs} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{job_key}") +async def get_job(job_key: str): + """Get a single job's details.""" + try: + job = await hrflow.get_job(job_key) + return job + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +class JobStatusPayload(BaseModel): + status: str + + +class CustomStagePayload(BaseModel): + label: str + color: str + + +class CustomStageOrderPayload(BaseModel): + order: list[str] + + +@router.patch("/{job_key}/status") +async def update_job_status(job_key: str, payload: JobStatusPayload): + """Update the operational status of a job.""" + try: + result = await hrflow.update_job_status(job_key, payload.status) + return {"ok": True, **result} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{job_key}/stages") +async def get_job_stages(job_key: str): + """List all stages for a job (built-in + custom).""" + try: + stages = await hrflow.get_job_stages(job_key) + return {"job_key": job_key, "stages": stages} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{job_key}/stages/presets") +async def get_preset_stages(): + """Return the list of preset stages HR can add.""" + return {"presets": hrflow.PRESET_STAGES} + + +@router.patch("/{job_key}/stages/reorder") +async def reorder_custom_stages(job_key: str, payload: CustomStageOrderPayload): + """Update the order of custom stages.""" + import json + try: + job = await hrflow.get_job(job_key) + custom_raw = hrflow.extract_tag(job, "custom_stages") + custom_stages = json.loads(custom_raw) if custom_raw else [] + + # Create a map for quick access + custom_map = {s["key"]: s for s in custom_stages} + new_ordered = [] + + # Rebuild custom_stages in new order + for i, key in enumerate(payload.order): + if key in custom_map: + s = custom_map[key] + s["order"] = i + 1 + new_ordered.append(s) + + existing_tags = [t for t in job.get("tags", []) if t.get("name") != "custom_stages"] + await hrflow.patch_job_tags(job_key, existing_tags + [{ + "name": "custom_stages", + "value": json.dumps(new_ordered) + }]) + + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{job_key}/stages") +async def create_custom_stage(job_key: str, payload: CustomStagePayload): + """Create a new custom stage for a job.""" + import json + import re + from datetime import datetime, timezone + try: + job = await hrflow.get_job(job_key) + custom_raw = hrflow.extract_tag(job, "custom_stages") + custom_stages = json.loads(custom_raw) if custom_raw else [] + + # Derive key if not provided (presets might have keys) + slug = re.sub(r'[^a-z0-9]+', '_', payload.label.lower()).strip('_') + key = f"custom_{slug}" + + # Collision guard + original_key = key + counter = 2 + existing_keys = {s["key"] for s in custom_stages} | {s["key"] for s in hrflow.MANDATORY_STAGES} + while key in existing_keys: + key = f"{original_key}_{counter}" + counter += 1 + + new_stage = { + "key": key, + "label": payload.label[:40], + "color": payload.color, + "order": max([s["order"] for s in custom_stages] + [0], default=0) + 1, + "created_at": datetime.now(timezone.utc).isoformat() + } + + custom_stages.append(new_stage) + existing_tags = [t for t in job.get("tags", []) if t.get("name") != "custom_stages"] + await hrflow.patch_job_tags(job_key, existing_tags + [{ + "name": "custom_stages", + "value": json.dumps(custom_stages) + }]) + + return {"ok": True, "job_key": job_key, **new_stage} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.delete("/{job_key}/stages/{stage_key}") +async def delete_custom_stage(job_key: str, stage_key: str): + """Delete a custom stage if not in use.""" + import json + if any(s["key"] == stage_key for s in hrflow.MANDATORY_STAGES): + raise HTTPException(status_code=403, detail="Cannot delete mandatory stages.") + + try: + # Check if in use + trackings = await hrflow.list_trackings(job_key) + in_use_count = 0 + for t in trackings: + p_key = t.get("profile_key") or t.get("profile", {}).get("key") + profile = await hrflow.get_profile(p_key) + tag_raw = hrflow.extract_tag(profile, f"stage_{job_key}") + if tag_raw: + t_data = json.loads(tag_raw) + if t_data.get("stage") == stage_key: + in_use_count += 1 + + if in_use_count > 0: + return { + "ok": False, + "error": "stage_in_use", + "message": f"{in_use_count} candidate(s) are currently assigned to this stage.", + "affected_count": in_use_count + } + + job = await hrflow.get_job(job_key) + custom_raw = hrflow.extract_tag(job, "custom_stages") + custom_stages = json.loads(custom_raw) if custom_raw else [] + + new_custom = [s for s in custom_stages if s["key"] != stage_key] + existing_tags = [t for t in job.get("tags", []) if t.get("name") != "custom_stages"] + await hrflow.patch_job_tags(job_key, existing_tags + [{ + "name": "custom_stages", + "value": json.dumps(new_custom) + }]) + + return {"ok": True, "job_key": job_key, "key": stage_key} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{job_key}/candidates") +async def get_job_candidates(job_key: str): + """ + Return the ranked list of candidates for a job. + Scores and stages are read from profile tags. + """ + try: + trackings = await hrflow.list_trackings(job_key) + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + candidates = [] + for tracking in trackings: + profile_key = tracking.get("profile_key") or tracking.get("profile", {}).get("key") + if not profile_key: + continue + + base_score = None + ai_adjustment = 0.0 + bonus = 0.0 + stage = "applied" + stage_updated_at = None + + try: + profile = await hrflow.get_profile(profile_key) + info = profile.get("info", {}) + + # Extract score data + score_tag = hrflow.extract_tag(profile, f"job_data_{job_key}") + if score_tag: + tag_data = json.loads(score_tag) + base_score = tag_data.get("base_score") + ai_adjustment = tag_data.get("ai_adjustment", 0.0) + bonus = tag_data.get("bonus", 0.0) + + # Extract stage data + stage_tag = hrflow.extract_tag(profile, f"stage_{job_key}") + if stage_tag: + s_data = json.loads(stage_tag) + stage = s_data.get("stage", "applied") + stage_updated_at = s_data.get("updated_at") + else: + # Fallback to tracking stage + stage = tracking.get("stage") or "applied" + + except Exception: + profile = {} + info = tracking.get("profile", {}).get("info", {}) + + score = (base_score + ai_adjustment) if base_score is not None else None + + candidates.append( + { + "profile_key": profile_key, + "first_name": info.get("first_name", ""), + "last_name": info.get("last_name", ""), + "email": info.get("email", ""), + "picture": info.get("picture", ""), + "base_score": base_score, + "ai_adjustment": ai_adjustment, + "score": score, + "bonus": bonus, + "stage": stage, + "stage_updated_at": stage_updated_at, + "tracking_key": tracking.get("key", ""), + } + ) + + # Sort: scored candidates first (desc), unscored last + candidates.sort(key=lambda c: (c["score"] is not None, c["score"] or 0), reverse=True) + return {"candidates": candidates} diff --git a/HEpiR-HREvolution/backend/services/__init__.py b/HEpiR-HREvolution/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/HEpiR-HREvolution/backend/services/hrflow.py b/HEpiR-HREvolution/backend/services/hrflow.py new file mode 100644 index 0000000..d86ddc5 --- /dev/null +++ b/HEpiR-HREvolution/backend/services/hrflow.py @@ -0,0 +1,492 @@ +"""Wrapper around the HRFlow REST API.""" + +import logging +import httpx +from config import settings + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.hrflow.ai/v1" + + +def _headers() -> dict: + return { + "X-API-KEY": settings.hrflow_api_key, + "X-USER-EMAIL": settings.hrflow_user_email, + } + + +# --------------------------------------------------------------------------- +# Jobs +# --------------------------------------------------------------------------- + +async def list_jobs(limit: int = 30, page: int = 1) -> list[dict]: + """Return jobs from the configured board using the searching endpoint.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/jobs/searching", + headers=_headers(), + params={ + "board_keys": f'["{settings.hrflow_board_key}"]', + "query": "", + "limit": limit, + "page": page, + }, + timeout=15, + ) + r.raise_for_status() + data = r.json() + jobs = (data.get("data") or {}).get("jobs", []) + # Enrich with status from tags + for job in jobs: + status_tag = extract_tag(job, "job_status") + if status_tag: + import json + try: + s_data = json.loads(status_tag) + job["status"] = s_data.get("status", "open") + job["status_updated_at"] = s_data.get("updated_at") + except: + job["status"] = "open" + else: + job["status"] = "open" + + print(f"HRFlow list_jobs → total={data.get('meta', {}).get('total')} returned={len(jobs)}", flush=True) + return jobs + + +async def get_job(job_key: str) -> dict: + """Return a single job by key.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/job/indexing", + headers=_headers(), + params={"board_key": settings.hrflow_board_key, "key": job_key}, + timeout=15, + ) + r.raise_for_status() + job = r.json().get("data", {}) + # Enrich status + status_tag = extract_tag(job, "job_status") + if status_tag: + import json + try: + s_data = json.loads(status_tag) + job["status"] = s_data.get("status", "open") + job["status_updated_at"] = s_data.get("updated_at") + except: + job["status"] = "open" + else: + job["status"] = "open" + return job + + +_JOB_WRITABLE = { + "name", "summary", "location", "skills", "tags", "metadatas", + "ranges_date", "ranges_float", "sections", "url", "reference" +} + +async def patch_job_tags(job_key: str, tags: list[dict]) -> dict: + """Update the tags field on a job (PUT with full mutable job payload).""" + job = await get_job(job_key) + payload: dict = {"board_key": settings.hrflow_board_key, "key": job_key, "tags": tags} + for field in _JOB_WRITABLE - {"tags"}: + if field in job and job[field] is not None: + payload[field] = job[field] + async with httpx.AsyncClient() as client: + r = await client.put( + f"{BASE_URL}/job/indexing", + headers={**_headers(), "Content-Type": "application/json"}, + json=payload, + timeout=15, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Profiles +# --------------------------------------------------------------------------- + +async def get_profile(profile_key: str) -> dict: + """Return a candidate profile by key.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/profile/indexing", + headers=_headers(), + params={"source_key": settings.hrflow_source_key, "key": profile_key}, + timeout=15, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +_PROFILE_WRITABLE = { + "reference", "info", "text", "summary", "cover_letter", + "experiences", "educations", "skills", "languages", "interests", + "tags", "metadatas", "certifications", "courses", "tasks", +} + +async def patch_profile_tags(profile_key: str, tags: list[dict]) -> dict: + """Update the tags field on a profile (PUT with full mutable profile payload).""" + profile = await get_profile(profile_key) + payload: dict = {"source_key": settings.hrflow_source_key, "key": profile_key, "tags": tags} + for field in _PROFILE_WRITABLE - {"tags"}: + if field in profile and profile[field] is not None: + payload[field] = profile[field] + async with httpx.AsyncClient() as client: + r = await client.put( + f"{BASE_URL}/profile/indexing", + headers={**_headers(), "Content-Type": "application/json"}, + json=payload, + timeout=15, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Trackings +# --------------------------------------------------------------------------- + +async def list_trackings(job_key: str) -> list[dict]: + """Return all trackings for a given job. Returns [] when none exist.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/trackings", + headers=_headers(), + params={ + "role": "candidate", + "board_key": settings.hrflow_board_key, + "job_key": job_key, + "source_keys": f'["{settings.hrflow_source_key}"]', + "limit": 100, + }, + timeout=15, + ) + if r.status_code == 404: + return [] + if not r.is_success: + print(f"list_trackings {job_key} → {r.status_code}: {r.text}", flush=True) + return [] + data = r.json() + + # Normalize the response structure + # The new endpoint returns the list in 'data' directly. + # The old endpoint returned it in 'data.trackings'. + data_content = data.get("data") + if isinstance(data_content, list): + return data_content + if isinstance(data_content, dict): + return data_content.get("trackings") or [] + return [] + + +async def get_tracking(job_key: str, profile_key: str) -> dict | None: + """Return the tracking linking a candidate to a specific job.""" + trackings = await list_trackings(job_key) + for t in trackings: + # Check both old (nested) and new (top-level) profile_key formats + p_key = t.get("profile_key") or t.get("profile", {}).get("key") + if p_key == profile_key: + return t + return None + + +# --------------------------------------------------------------------------- +# Scoring (HRFlow native) +# --------------------------------------------------------------------------- + +async def get_profile_score(job_key: str, profile_key: str) -> float | None: + """Return the HRFlow grading score for a profile against a job. + Returns None (non-fatal) on 400/404 — profile may not be indexed yet. + """ + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/profile/grading", + headers=_headers(), + params={ + "board_key": settings.hrflow_board_key, + "source_key": settings.hrflow_source_key, + "algorithm_key": "grader-hrflow-profiles", + "job_key": job_key, + "profile_key": profile_key, + }, + timeout=20, + ) + if r.status_code in (400, 404): + print(f"[get_profile_score] {r.status_code} {r.text[:200]}", flush=True) + return None + r.raise_for_status() + data = r.json() + score = data.get("data", {}).get("score") + print(f"[get_profile_score] score={score}", flush=True) + return score + + +async def get_job_upskilling(job_key: str, profile_key: str) -> dict: + """Return upskilling data (strengths, weaknesses, skill gaps) for a profile vs job.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/job/upskilling", + headers=_headers(), + params={ + "board_key": settings.hrflow_board_key, + "job_key": job_key, + "source_key": settings.hrflow_source_key, + "profile_key": profile_key, + }, + timeout=20, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Profile parsing (resume upload) +# --------------------------------------------------------------------------- + +async def parse_resume_file(file_bytes: bytes, filename: str) -> dict: + """Upload a PDF resume to HRFlow for parsing. Returns the created profile.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{BASE_URL}/profile/parsing/file", + headers=_headers(), + data={ + "source_key": settings.hrflow_source_key, + "sync_parsing": "1", + }, + files={"file": (filename, file_bytes, "application/pdf")}, + timeout=60, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Job creation +# --------------------------------------------------------------------------- + +async def create_job(payload: dict) -> dict: + """Create a new job in the configured HRFlow board via job/indexing.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{BASE_URL}/job/indexing", + headers={**_headers(), "Content-Type": "application/json"}, + json={"board_key": settings.hrflow_board_key, **payload}, + timeout=20, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Tracking creation +# --------------------------------------------------------------------------- + + +async def create_tracking(job_key: str, profile_key: str, stage: str = "applied") -> dict: + """Create a tracking entry linking a profile to a job.""" + payload = { + "board_key": settings.hrflow_board_key, + "source_key": settings.hrflow_source_key, + "job_key": job_key, + "profile_key": profile_key, + "stage": stage, + "role": "candidate", + } + async with httpx.AsyncClient() as client: + r = await client.post( + f"{BASE_URL}/tracking", + headers={**_headers(), "Content-Type": "application/json"}, + json=payload, + timeout=20, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def extract_tag(obj: dict, name: str): + """Extract a tag value by name from an object's (job or profile) tags list.""" + for tag in obj.get("tags", []) or []: + if tag.get("name") == name: + return tag.get("value") + return None + + +EXTRA_DOC_PREFIX = "extra_doc_" +MAX_DOC_CONTENT = 8_000 + + +async def patch_profile_metadatas(profile_key: str, metadatas: list[dict]) -> dict: + """Update the metadatas field on a profile (PUT with full mutable profile payload).""" + profile = await get_profile(profile_key) + payload: dict = {"source_key": settings.hrflow_source_key, "key": profile_key, "metadatas": metadatas} + for field in _PROFILE_WRITABLE - {"metadatas"}: + if field in profile and profile[field] is not None: + payload[field] = profile[field] + async with httpx.AsyncClient() as client: + r = await client.put( + f"{BASE_URL}/profile/indexing", + headers={**_headers(), "Content-Type": "application/json"}, + json=payload, + timeout=15, + ) + r.raise_for_status() + return r.json().get("data", {}) + + +def get_extra_documents(profile: dict, job_key: str) -> list[dict]: + """Extract extra HR documents for a given job from a profile's metadatas.""" + import json as _json + prefix = f"{EXTRA_DOC_PREFIX}{job_key}_" + docs = [] + for meta in profile.get("metadatas", []) or []: + name = meta.get("name", "") + if name.startswith(prefix): + try: + doc = _json.loads(meta.get("value", "{}")) + doc["id"] = name + docs.append(doc) + except Exception: + pass + docs.sort(key=lambda d: d.get("uploaded_at", "")) + return docs + + +async def add_extra_document(profile_key: str, job_key: str, filename: str, content: str, uploaded_by: str = "") -> str: + """Append an extra document to a profile's metadatas. Returns the metadata name (id).""" + import json as _json + import time as _time + from datetime import datetime, timezone + profile = await get_profile(profile_key) + existing = list(profile.get("metadatas", []) or []) + ts = int(_time.time()) + name = f"{EXTRA_DOC_PREFIX}{job_key}_{ts}" + existing.append({ + "name": name, + "value": _json.dumps({ + "job_key": job_key, + "filename": filename or f"document_{ts}.txt", + "content": content[:MAX_DOC_CONTENT], + "uploaded_by": uploaded_by, + "uploaded_at": datetime.now(timezone.utc).isoformat(), + }), + }) + await patch_profile_metadatas(profile_key, existing) + return name + + +async def update_documents_with_deltas(profile_key: str, job_key: str, scored_docs: list[dict]) -> None: + """Write AI-computed delta and rationale into each document's metadata entry.""" + import json as _json + profile = await get_profile(profile_key) + scored_map = {d["id"]: d for d in scored_docs} + updated_metadatas = [] + for meta in profile.get("metadatas", []) or []: + name = meta.get("name", "") + if name.startswith(f"{EXTRA_DOC_PREFIX}{job_key}_") and name in scored_map: + try: + doc_data = _json.loads(meta.get("value", "{}")) + doc_data["delta"] = scored_map[name].get("delta", 0.0) + doc_data["delta_rationale"] = scored_map[name].get("rationale", "") + meta = {"name": name, "value": _json.dumps(doc_data)} + except Exception: + pass + updated_metadatas.append(meta) + await patch_profile_metadatas(profile_key, updated_metadatas) + + +def build_job_tag(job_key: str, score: float, bonus: float = 0.0, base_score: float = None) -> dict: + """Build a HRFlow tag dict for storing job scoring data.""" + import json + return { + "name": f"job_data_{job_key}", + "value": json.dumps({ + "job_key": job_key, + "base_score": base_score, + "ai_adjustment": score - (base_score or 0), # Simplified for build_job_tag if base_score is passed + "bonus": bonus + }), + } + +# --------------------------------------------------------------------------- +# Status & Stages Management +# --------------------------------------------------------------------------- + +MANDATORY_STAGES = [ + {"key": "applied", "label": "Applied", "color": "gray", "order": 0, "builtin": True}, + {"key": "hired", "label": "Hired", "color": "green", "order": 999, "builtin": True}, + {"key": "rejected", "label": "Rejected", "color": "red", "order": 1000, "builtin": True}, +] + +# Presets that HR can add easily +PRESET_STAGES = [ + {"key": "screening", "label": "Screening", "color": "blue"}, + {"key": "interview", "label": "Interview", "color": "indigo"}, + {"key": "technical_test", "label": "Technical Test", "color": "purple"}, + {"key": "offer", "label": "Offer Sent", "color": "orange"}, +] + +async def get_job_stages(job_key: str) -> list[dict]: + """Return Applied + custom/preset stages + Hired + Rejected.""" + import json + job = await get_job(job_key) + custom_raw = extract_tag(job, "custom_stages") + custom_stages = json.loads(custom_raw) if custom_raw else [] + + # Custom stages are placed between Applied (0) and Hired (999) + # We ensure they have a valid order. If not, they follow Applied. + processed_custom = [] + for i, s in enumerate(custom_stages): + processed_custom.append({ + "builtin": False, + **s, + "order": s.get("order", i + 1) + }) + + processed_custom.sort(key=lambda x: x["order"]) + + # Re-normalize orders to be between 1 and 998 + for i, s in enumerate(processed_custom): + s["order"] = i + 1 + + all_stages = [MANDATORY_STAGES[0]] + processed_custom + MANDATORY_STAGES[1:] + return all_stages + + +async def update_job_status(job_key: str, status: str) -> dict: + """Update job operational status.""" + import json + from datetime import datetime, timezone + job = await get_job(job_key) + existing_tags = [t for t in job.get("tags", []) if t.get("name") != "job_status"] + + updated_at = datetime.now(timezone.utc).isoformat() + new_tag = { + "name": "job_status", + "value": json.dumps({"status": status, "updated_at": updated_at}) + } + await patch_job_tags(job_key, existing_tags + [new_tag]) + return {"status": status, "updated_at": updated_at} + + +async def update_candidate_stage(profile_key: str, job_key: str, stage: str) -> dict: + """Update candidate recruitment stage for a specific job.""" + import json + from datetime import datetime, timezone + profile = await get_profile(profile_key) + tag_name = f"stage_{job_key}" + existing_tags = [t for t in profile.get("tags", []) if t.get("name") != tag_name] + + updated_at = datetime.now(timezone.utc).isoformat() + new_tag = { + "name": tag_name, + "value": json.dumps({"job_key": job_key, "stage": stage, "updated_at": updated_at}) + } + await patch_profile_tags(profile_key, existing_tags + [new_tag]) + return {"stage": stage, "updated_at": updated_at} diff --git a/HEpiR-HREvolution/backend/services/llm.py b/HEpiR-HREvolution/backend/services/llm.py new file mode 100644 index 0000000..060c101 --- /dev/null +++ b/HEpiR-HREvolution/backend/services/llm.py @@ -0,0 +1,268 @@ +"""LLM service using an OpenAI-compatible API (OpenRouter by default).""" + +import base64 +import json +from openai import AsyncOpenAI +from config import settings + +_client: AsyncOpenAI | None = None + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + api_key=settings.llm_api_key, + base_url=settings.llm_base_url, + ) + return _client + + +async def _chat(system: str, user: str) -> str: + client = _get_client() + response = await client.chat.completions.create( + model=settings.llm_model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.3, + ) + return response.choices[0].message.content.strip() + + +async def transcribe_audio(audio_bytes: bytes, filename: str) -> str: + """Transcribe audio using OpenRouter multimodal capabilities.""" + client = _get_client() + # Base64 encode the audio data + encoded = base64.b64encode(audio_bytes).decode("utf-8") + + # Extract format from filename (default to mp3 if not found) + fmt = filename.split(".")[-1].lower() + if fmt not in ["mp3", "m4a", "wav", "aac", "ogg", "flac", "aiff"]: + fmt = "mp3" + + # Use a multimodal model for audio transcription. + # Google's gemini-2.0-flash is great for this and often has a free tier. + # We use a specific model that supports audio input. + model = "google/gemini-2.0-flash-001" + + response = await client.chat.completions.create( + model=model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Please provide a clean transcription of this audio file. Output only the transcript text."}, + { + "type": "input_audio", + "input_audio": { + "data": encoded, + "format": fmt, + } + } + ] + } + ], + ) + return response.choices[0].message.content.strip() + + +# --------------------------------------------------------------------------- +# Per-document scoring +# --------------------------------------------------------------------------- + +DOCUMENT_SCORE_SYSTEM = """You are an expert HR evaluator scoring a single supplementary document attached to a candidate profile. + +Your task: assign a delta score (-0.2 to +0.2) representing the net signal THIS document alone contributes to the evaluation. + +Context provided: +- The single document to score +- The candidate's CV/Profile claims (skills, experiences) +- All other already-attached documents (for cross-document analysis) + +Scoring rules: +- POSITIVE delta (+0.01 to +0.2): document reveals strengths, achievements, or qualities that genuinely support the candidate's fit. +- NEAR ZERO (0.0): document is neutral, redundant, or doesn't add meaningful new signal. +- NEGATIVE delta (-0.01 to -0.2): document contains an explicit red flag OR directly CONTRADICTS a specific claim made in the CV or another document (e.g., CV says they are "Expert in Python" but an interview transcript shows they don't know basic syntax). + +Critical: a document that is simply "less impressive" than another is NOT a contradiction — assign 0 or a small positive, never negative. Only genuine factual contradictions or explicit red flags warrant a negative delta. + +Do NOT re-evaluate the candidate against the job — HRFlow already handles that. Only assess what this specific document uniquely adds, reveals, or contradicts. + +Respond ONLY with valid JSON: +{ + "delta": , + "rationale": "" +}""" + + +async def score_single_document( + job: dict, + profile: dict, + document: dict, + other_docs: list[dict], +) -> dict: + """Score a single supplementary document in the context of all other documents. + Returns {"delta": float, "rationale": str}. + """ + user_content = json.dumps({ + "job_title": job.get("name", ""), + "candidate_name": f"{profile.get('info', {}).get('first_name', '')} {profile.get('info', {}).get('last_name', '')}", + "cv_claims": { + "skills": [_skill_name(s) for s in profile.get("skills", [])], + "experiences": [e.get("title") for e in profile.get("experiences", [])], + }, + "document_to_score": { + "filename": document.get("filename", ""), + "content": document.get("content", ""), + }, + "other_documents": [ + {"filename": d.get("filename", ""), "content": d.get("content", "")} + for d in other_docs + ], + }, ensure_ascii=False) + raw = await _chat(DOCUMENT_SCORE_SYSTEM, user_content) + try: + result = json.loads(raw) + delta = max(-0.2, min(0.2, float(result.get("delta", 0.0)))) + return {"delta": round(delta, 3), "rationale": result.get("rationale", "")} + except (json.JSONDecodeError, ValueError): + return {"delta": 0.0, "rationale": raw} + + +# --------------------------------------------------------------------------- +# Synthesis +# --------------------------------------------------------------------------- + +SYNTHESIS_SYSTEM = """You are an expert HR analyst. Given a job, a candidate profile, their +application data, extra documents (like interview transcripts or technical tests), and scoring analysis, +write a concise structured recruitment summary. + +Critical Instruction on Contradictions: +- Compare the candidate's claims (from CV/profile) with evidence from extra documents. +- If an extra document (e.g., an interview) reveals a weakness or lack of skill that contradicts a claim in the CV, + PRIORITIZE the evidence from the extra document and explicitly mention this contradiction in the summary. +- Adjust strengths and weaknesses accordingly: what was a "strength" in the CV might become a "weakness" if the + interview evidence shows they actually lack that skill. + +Rules for strengths and weaknesses: +- strengths: skills, experiences, or qualities that directly match or exceed the job requirements, + verified across ALL available documents. +- weaknesses: ONLY skills or experiences that are EXPLICITLY required by the job description AND clearly absent + from the candidate's profile OR proven to be lacking by evidence in the extra documents (e.g. an interview). + A skill not mentioned anywhere in the job offer is NOT a weakness, even if the candidate does not have it. + Do NOT invent weaknesses. If there are no genuine weaknesses, return an empty array. +- upskilling: concrete learning recommendations to close ONLY the gaps identified as real weaknesses above. + Do NOT add upskilling recommendations for skills not required by the job. + +Respond ONLY with valid JSON — no markdown, no code fences, no extra keys. +Every value in "strengths", "weaknesses", and "upskilling" MUST be a plain string, not an object. +{ + "summary": "<2-3 sentence narrative, explicitly noting any major contradictions found between the CV and extra documents>", + "strengths": ["", "", ...], + "weaknesses": ["", ...], + "upskilling": ["", ...] +}""" + + +async def synthesize_candidate( + job: dict, + profile: dict, + tracking: dict, + upskilling: dict, + final_score: float, + extra_docs: list[dict] = None, +) -> dict: + """Generate a structured candidate synthesis.""" + user_content = json.dumps( + { + "final_score": final_score, + "job_title": job.get("name", ""), + "job_summary": job.get("summary", ""), + "job_skills": [_skill_name(s) for s in job.get("skills", [])], + "candidate_name": f"{profile.get('info', {}).get('first_name', '')} {profile.get('info', {}).get('last_name', '')}", + "candidate_skills": [_skill_name(s) for s in profile.get("skills", [])], + "candidate_experiences": [ + e.get("title") for e in profile.get("experiences", []) + ], + "cover_letter": tracking.get("message", ""), + "quiz_answers": tracking.get("answers", []), + "extra_documents": [ + { + "filename": d.get("filename", ""), + "content": d.get("content", ""), + "ai_delta": d.get("delta", 0.0), + "ai_rationale": d.get("delta_rationale", "") + } + for d in (extra_docs or []) + ], + "strengths": upskilling.get("strengths", []), + "weaknesses": upskilling.get("weaknesses", []), + "skill_gaps": upskilling.get("skill_gaps", []), + }, + ensure_ascii=False, + ) + raw = await _chat(SYNTHESIS_SYSTEM, user_content) + try: + return json.loads(raw) + except json.JSONDecodeError: + return {"summary": raw, "strengths": [], "weaknesses": [], "upskilling": []} + + +# --------------------------------------------------------------------------- +# Ask — interview question generator +# --------------------------------------------------------------------------- + +ASK_SYSTEM = """You are an expert interviewer. Given a job description, a candidate profile, and supplementary documents (like interview transcripts or technical tests), +generate targeted interview questions that probe the candidate's fit, technical skills, and motivation. + +CRITICAL INSTRUCTIONS: +1. FOCUS ON THE JOB: Every question must be directly relevant to the specific job title and job description provided. +2. USE ALL EVIDENCE: Use the candidate's CV/profile AND the extra documents to identify gaps, contradictions, or areas needing deeper investigation relative to the job requirements. +3. BE SPECIFIC: Avoid generic questions. Refer to specific skills or experiences found in the job description or candidate profile. + +Respond ONLY with valid JSON: +{ + "questions": [ + {"category": "", "question": ""}, + ... + ] +}""" + + +def _skill_name(s) -> str: + return s.get("name", "") if isinstance(s, dict) else str(s) + + +async def generate_questions(job: dict, profile: dict, extra_docs: list[dict] = None) -> dict: + """Generate tailored interview questions for a candidate.""" + user_content = json.dumps( + { + "job_title": job.get("name", ""), + "job_summary": job.get("summary", ""), + "job_skills": [_skill_name(s) for s in job.get("skills", [])], + "candidate_name": f"{profile.get('info', {}).get('first_name', '')} {profile.get('info', {}).get('last_name', '')}", + "candidate_skills": [_skill_name(s) for s in profile.get("skills", [])], + "candidate_experiences": [ + { + "title": e.get("title"), + "company": (e.get("company") or {}).get("name", "") if isinstance(e.get("company"), dict) else (e.get("company") or ""), + } + for e in profile.get("experiences", []) + ], + "extra_documents": [ + { + "filename": d.get("filename", ""), + "content": d.get("content", ""), + } + for d in (extra_docs or []) + ], + }, + ensure_ascii=False, + ) + raw = await _chat(ASK_SYSTEM, user_content) + try: + return json.loads(raw) + except json.JSONDecodeError: + return {"questions": [{"category": "General", "question": raw}]} diff --git a/HEpiR-HREvolution/docker-compose.yml b/HEpiR-HREvolution/docker-compose.yml new file mode 100644 index 0000000..f650aba --- /dev/null +++ b/HEpiR-HREvolution/docker-compose.yml @@ -0,0 +1,29 @@ +services: + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8080:8080" + env_file: + - .env + volumes: + - ./backend:/app:z + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - VITE_API_URL=http://backend:8080 + depends_on: + - backend + volumes: + - ./frontend:/app:z + - frontend_nm:/app/node_modules + +volumes: + frontend_nm: diff --git a/HEpiR-HREvolution/docs/ai-pipeline.md b/HEpiR-HREvolution/docs/ai-pipeline.md new file mode 100644 index 0000000..58333b4 --- /dev/null +++ b/HEpiR-HREvolution/docs/ai-pipeline.md @@ -0,0 +1,139 @@ +# AI Pipeline + +## Model + +**Provider:** OpenRouter (`https://openrouter.ai/api/v1`) +**Model:** `nvidia/nemotron-3-super-120b-a12b:free` +**Config:** `backend/config.py` → `llm_model` (overridable via `LLM_MODEL` env var) + +All LLM calls use the OpenAI-compatible SDK (`openai.AsyncOpenAI`), `temperature=0.3`. + +--- + +## Grading Pipeline — `POST /api/ai/grade` + +**Triggered automatically** after resume upload and after each extra document is added. Returns immediately once scores are computed — synthesis is a separate step. + +### Steps + +``` +1. fetch job → GET /v1/job/indexing +2. fetch profile → GET /v1/profile/indexing +3. read base_score → from job_data_{job_key} tag (if cached, skip HRFlow call) + (if not cached) → GET /v1/profile/grading + → write base_score to job_data_{job_key} tag immediately +4. for each extra document WITHOUT a stored delta: + → LLM score_single_document(job, profile, doc, other_docs) → {delta, rationale} + → write delta + delta_rationale back into the document's metadata entry +5. ai_adjustment = sum(all deltas), capped at ±0.3 +6. write job_data_{job_key} tag: { job_key, base_score, ai_adjustment, bonus } +7. return { base_score, ai_adjustment } +``` + +> Documents that already have a stored `delta` are **never re-scored**. Their delta is frozen once computed. Only newly added documents trigger LLM calls. + +> `base_score` is cached in the profile tag after the first grade. Subsequent grades reuse it without calling HRFlow, since the algorithmic score only changes when the profile itself changes. + +### Per-Document Scoring + +Each extra document is scored individually by the LLM in the context of all other attached documents. + +**Delta range:** −0.2 to +0.2 per document +**Total `ai_adjustment`:** sum of all deltas, hard-capped at ±0.3 + +| Delta | Meaning | +|-------|---------| +| +0.01 to +0.2 | Document reveals genuine strengths, achievements, or qualities that support the candidate's fit | +| ~0.0 | Document is neutral, redundant, or doesn't add new signal | +| -0.01 to -0.2 | Document contains an explicit red flag, or **directly contradicts** a specific positive claim made in another document | + +> A document that is simply "less impressive" than another is **not** a contradiction. Only genuine factual contradictions or explicit red flags produce a negative delta. + +### Score Formula + +``` +total = min(1.0, max(0.0, base_score + ai_adjustment + bonus)) +``` + +| Component | Source | +|-----------|--------| +| `base_score` | HRFlow native grading (`/v1/profile/grading`), cached after first fetch | +| `ai_adjustment` | Sum of per-document deltas from LLM, capped ±0.3 | +| `bonus` | HR manual adjustment (−1.0 to +1.0), stored separately | + +### Score Storage + +```json +{ "name": "job_data_{job_key}", "value": "{\"job_key\": \"...\", \"base_score\": 0.65, \"ai_adjustment\": 0.12, \"bonus\": 0.0}" } +``` + +HR can apply a bonus offset (−1.0 to +1.0) via `PATCH /api/candidates/{profile_key}/bonus`. + +--- + +## Synthesis — `GET /api/ai/synthesis` + `POST /api/ai/synthesize` + +Synthesis is a **separate step** from grading. The grade endpoint returns immediately with updated scores; the frontend then triggers synthesis via a second call. + +### Auto-generation flow + +When a candidate panel is opened: +1. `GET /api/ai/synthesis` — reads from HRFlow profile tag `synthesis_{job_key}` +2. If `null` returned → frontend calls `POST /api/ai/synthesize` automatically +3. Synthesis is stored in HRFlow tag +4. Next panel open → served from tag instantly + +After grading (triggered by document upload or initial upload): +1. Grade completes → score display updates (Phase 1) +2. Frontend calls `POST /api/ai/synthesize` → synthesis banner shown (Phase 2) +3. Synthesis tag updated + +### Synthesis Prompt Rules + +- **Strengths:** skills/experiences that match or exceed job requirements. Additional specialties are positive or neutral. +- **Weaknesses:** ONLY explicitly required skills that are clearly missing. Never flag extra skills or unrelated specialisations as weaknesses. +- **Upskilling:** concrete recommendations to close actual required-skill gaps only. + +### Synthesis Output Schema + +```json +{ + "summary": "2-3 sentence narrative", + "strengths": ["strength 1", "strength 2"], + "weaknesses": ["weakness 1"], + "upskilling": ["recommendation 1"], + "verdict": "strong_yes | yes | maybe | no" +} +``` + +### Storage + +Synthesis is stored as a HRFlow profile tag `synthesis_{job_key}`. No in-memory cache — always read from HRFlow on panel open. + +--- + +## Interview Questions — `POST /api/ai/ask` + +Generates tailored questions from job + profile data. Not persisted (generated on demand). Displayed inline in the **Ask tab** of the candidate panel. + +### Output Schema + +```json +{ + "questions": [ + { "category": "Technical", "question": "..." }, + { "category": "Behavioral", "question": "..." }, + { "category": "Motivation", "question": "..." } + ] +} +``` + +--- + +## Robustness Notes + +- **Skill fields from HRFlow** can be either `{"name": "Python"}` dicts or plain strings. All skill list comprehensions use `_skill_name(s)` helper that handles both. +- **Experience `company` field** can be a string or `{"name": "..."}` dict. The company name extraction guards with `isinstance(e.get("company"), dict)`. +- **Upskilling fetch failures** are non-fatal in the synthesize endpoint — caught silently, `upskilling` defaults to `{}`. +- **Grading 400/404** — if HRFlow hasn't indexed the profile yet, `base_score` defaults to `0.0` and grading continues. +- **LLM JSON parse failure** — delta scoring falls back to `{delta: 0.0, rationale: raw_text}`; synthesis falls back to `{summary: raw_text, ...defaults}`. diff --git a/HEpiR-HREvolution/docs/api-reference.md b/HEpiR-HREvolution/docs/api-reference.md new file mode 100644 index 0000000..a26bd7f --- /dev/null +++ b/HEpiR-HREvolution/docs/api-reference.md @@ -0,0 +1,260 @@ +# Backend API Reference + +Base URL: `http://localhost:8080/api` +Swagger UI: `http://localhost:8080/docs` + +> All routes use `redirect_slashes=False`. Do not add trailing slashes. + +--- + +## Jobs — `/api/jobs` + +### `GET /api/jobs` +List all jobs from the configured HRFlow board. + +**Response** +```json +{ "jobs": [ { "key": "...", "name": "...", ... } ] } +``` + +--- + +### `POST /api/jobs` +Create a new job in the HRFlow board. + +**Body** +```json +{ + "name": "Senior Frontend Engineer", + "summary": "Role description...", + "location": "Paris, France", + "skills": [ + { "name": "React", "value": "advanced" }, + { "name": "TypeScript", "value": "intermediate" } + ] +} +``` + +Skill `value` is one of: `beginner`, `intermediate`, `advanced`, `expert`. +HRFlow receives: `{ "name": "React", "type": "hard", "value": "advanced" }`. + +**Response** +```json +{ "ok": true, "job_key": "abc123", "name": "Senior Frontend Engineer" } +``` + +--- + +### `GET /api/jobs/{job_key}` +Get a single job's full HRFlow data. + +--- + +### `GET /api/jobs/{job_key}/candidates` +Return the ranked candidate list for a job. + +Fetches all trackings for the job, then for each profile reads the cached `job_data_{job_key}` tag. Candidates with no stored score show `score: null`. Results are sorted: scored first (descending), unscored last. + +**Response** +```json +{ + "candidates": [ + { + "profile_key": "...", + "first_name": "Alice", + "last_name": "Martin", + "email": "alice@example.com", + "score": 0.88, + "bonus": 0.05, + "stage": "interview", + "tracking_key": "..." + } + ] +} +``` + +> `score` in the list response is the pre-computed total (`base_score + ai_adjustment + bonus`), capped at 1.0. + +--- + +## Candidates — `/api/candidates` + +### `POST /api/candidates/upload` +Upload a PDF resume. Creates a HRFlow profile and, if `job_key` is provided, creates a tracking entry linking the profile to the job. + +**Body** — multipart/form-data +- `file`: PDF file +- `job_key` *(optional)*: if present, a tracking with stage `applied` is created automatically + +**Response** +```json +{ "ok": true, "profile_key": "xyz789", "name": "Bob Durand", "email": "bob@example.com" } +``` + +> Grading and synthesis are **not** triggered by this endpoint. They are initiated by the caller (frontend) in the background after upload completes. + +--- + +### `GET /api/candidates/{profile_key}` +Return the full HRFlow profile object (info, skills, experiences, educations, attachments, tags, metadatas…). + +--- + +### `GET /api/candidates/{profile_key}/score?job_key=...` +Read the stored score for a candidate on a specific job from profile tags. + +**Response** +```json +{ "job_key": "...", "base_score": 0.65, "ai_adjustment": 0.12, "bonus": 0.0 } +``` + +--- + +### `PATCH /api/candidates/{profile_key}/bonus` +Update the HR bonus for a candidate on a specific job (preserves existing scores). + +**Body** +```json +{ "job_key": "...", "bonus": 0.1 } +``` + +--- + +### `GET /api/candidates/{profile_key}/documents?job_key=...` +List extra documents attached to a candidate for a specific job. + +**Response** +```json +{ + "documents": [ + { + "id": "extra_doc_abc123_1711634400", + "filename": "interview_notes.txt", + "content": "Candidate demonstrated...", + "uploaded_by": "hr@company.com", + "uploaded_at": "2026-03-28T14:00:00Z", + "delta": 0.08, + "delta_rationale": "Document reveals strong leadership experience directly relevant to the role." + } + ] +} +``` + +> `delta` and `delta_rationale` are `null` on newly uploaded documents until grading runs. + +--- + +### `POST /api/candidates/{profile_key}/documents` +Add a new text document to a candidate's profile for a specific job. + +**Body** +```json +{ + "job_key": "abc123", + "filename": "interview_notes.txt", + "content": "Full text content..." +} +``` + +**Response** +```json +{ "ok": true, "id": "extra_doc_abc123_1711634400" } +``` + +--- + +### `POST /api/candidates/{profile_key}/documents/file` +Upload a file (PDF, DOCX, or audio). The backend extracts or transcribes text and stores it as a document. + +**Body** — `multipart/form-data` +- `job_key` (string, required) +- `file` (binary) — accepted extensions: `.pdf`, `.docx`, `.doc`, `.mp3`, `.m4a`, `.wav`, `.aac`, `.ogg`, `.flac`, `.aiff`, `.txt` + +**Processing by type:** +- **PDF** — text extracted via `pypdf` +- **DOCX/DOC** — paragraph text extracted via `python-docx` +- **Audio** — transcribed via `google/gemini-2.0-flash-001` through OpenRouter +- **TXT / other** — decoded as UTF-8 + +**Response** +```json +{ "ok": true, "id": "extra_doc_abc123_1711634400", "content": "" } +``` + +**Errors:** `400` if unsupported format or no text could be extracted; `502` on upstream failure. + +--- + +## AI — `/api/ai` + +### `POST /api/ai/grade` +Score calculation pipeline. Fetches or reuses cached HRFlow base score, scores any newly added extra documents via LLM, and stores updated scores in the profile tag. + +Returns immediately — synthesis is **not** included. The frontend triggers synthesis separately via `POST /api/ai/synthesize`. + +**Body** +```json +{ "job_key": "...", "profile_key": "..." } +``` + +**Response** +```json +{ + "base_score": 0.65, + "ai_adjustment": 0.12 +} +``` + +| Field | Description | +|-------|-------------| +| `base_score` | HRFlow native score (cached from first grade; not re-fetched on subsequent calls) | +| `ai_adjustment` | Sum of per-document LLM deltas, capped at ±0.3. Only new documents (no stored delta) are scored. | + +--- + +### `GET /api/ai/synthesis?job_key=...&profile_key=...` +Return stored synthesis from the candidate's HRFlow profile tag. Returns `null` if not yet generated. + +**Response** — synthesis object or `null` +```json +{ + "summary": "Alice is a strong fit...", + "strengths": ["React expertise", "Team leadership"], + "weaknesses": ["Limited backend exposure"], + "upskilling": ["Consider a Node.js course"], + "verdict": "yes" +} +``` + +--- + +### `POST /api/ai/synthesize` +(Re-)generate synthesis using job, profile, tracking, and upskilling data. Stores result in HRFlow profile tag and returns it. + +**Body** +```json +{ "job_key": "...", "profile_key": "..." } +``` + +**Response** — synthesis object (same schema as `GET /api/ai/synthesis`) + +--- + +### `POST /api/ai/ask` +Generate tailored interview questions for a candidate / job pair. Not persisted. + +**Body** +```json +{ "job_key": "...", "profile_key": "..." } +``` + +**Response** +```json +{ + "questions": [ + { "category": "Technical", "question": "Describe your approach to state management in React." }, + { "category": "Behavioral", "question": "Tell me about a time you led a cross-functional project." }, + { "category": "Motivation", "question": "Why are you interested in this role specifically?" } + ] +} +``` diff --git a/HEpiR-HREvolution/docs/architecture.md b/HEpiR-HREvolution/docs/architecture.md new file mode 100644 index 0000000..75668e3 --- /dev/null +++ b/HEpiR-HREvolution/docs/architecture.md @@ -0,0 +1,91 @@ +# Architecture Overview + +## System Design + +Stateless gateway architecture — no local database. All persistent data lives in HRFlow (profiles, jobs, trackings, tags). The backend is a thin orchestration layer between the React frontend, the HRFlow REST API, and an OpenRouter-hosted LLM. + +``` +Browser (React/Vite) + | + | HTTP /api/* + v +FastAPI Backend (Python) + | + |—— HRFlow REST API (jobs, profiles, trackings, scoring, upskilling) + |—— OpenRouter LLM (grading, synthesis, interview questions) +``` + +## Tech Stack + +| Layer | Technology | +|----------|-------------------------------------| +| Frontend | React 18, Vite 5, plain CSS-in-JS | +| Backend | Python 3.12, FastAPI, uvicorn | +| AI | OpenRouter (OpenAI-compatible API) | +| HR Data | HRFlow API v1 | +| Infra | Docker Compose | + +## File Structure + +``` +HRFlow/ +├── docker-compose.yml +├── .env ← secrets (not committed) +├── .env.example +├── PROJECT_DOCS.md +├── docs/ ← this directory +│ +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── main.py ← FastAPI app, CORS, router registration +│ ├── config.py ← pydantic-settings env loader +│ ├── routers/ +│ │ ├── jobs.py ← job listing, creation, candidate ranking +│ │ ├── candidates.py ← profile CRUD, PDF upload, score/bonus storage +│ │ └── ai.py ← grade, synthesize (with cache), ask +│ └── services/ +│ ├── hrflow.py ← HRFlow API wrapper (all HTTP calls) +│ └── llm.py ← OpenRouter LLM calls, prompt definitions +│ +└── frontend/ + ├── Dockerfile + ├── package.json + ├── vite.config.js ← /api proxy → http://backend:8080 + ├── index.html + └── src/ + ├── index.css ← design tokens, global resets + ├── main.jsx + ← App.jsx + ├── services/ + │ └── api.js ← all fetch helpers + ├── pages/ + │ └── DashboardPage.jsx + └── components/ + ├── Sidebar.jsx + ├── JobView.jsx + ├── CandidatePanel.jsx + ├── DocumentsTab.jsx ← extra documents tab (upload, delta badges, viewer) + ├── AskAssistant.jsx ← interview questions (inline tab or overlay) + ├── CreateJobModal.jsx + └── UploadResumeModal.jsx +``` + +## Data Persistence Strategy + +| Data type | Where stored | +|----------------|-------------------------------------------| +| Jobs | HRFlow Board (`job/indexing`) | +| Profiles | HRFlow Source (`profile/indexing`) | +| Trackings | HRFlow Tracking (`tracking/indexing`) | +| Scores | HRFlow profile tag `job_data_` (`base_score`, `ai_adjustment`, `bonus`) | +| Synthesis | HRFlow profile tag `synthesis_` | +| Extra documents| HRFlow profile metadatas (`extra_doc__`) | + +## HRFlow Indexing Delay Workaround + +HRFlow's search index (`/jobs/searching`, `/tracking/list`) has a latency of several seconds to minutes after a write. Two mitigations are in place: + +- **Jobs:** `localStorage` stores newly created job keys. `fetchJobs()` fetches each pending key individually via `GET /job/indexing` until it appears in search results, then clears it from localStorage. +- **Candidates:** Same pattern per job. After upload, the profile key is registered in `localStorage` under `hrflow_pending_candidates_{job_key}`. `fetchCandidates()` fetches each pending profile individually until it appears in trackings. +- **Synthesis:** Stored in HRFlow profile tag `synthesis_{job_key}`. Read directly from HRFlow on panel open — no in-memory cache. diff --git a/HEpiR-HREvolution/docs/candidate-extra-documents.md b/HEpiR-HREvolution/docs/candidate-extra-documents.md new file mode 100644 index 0000000..014a499 --- /dev/null +++ b/HEpiR-HREvolution/docs/candidate-extra-documents.md @@ -0,0 +1,298 @@ +# Candidate Extra Documents + +## Overview + +HR can attach supplementary text documents to a candidate's profile for a given job. These documents are stored in the HRFlow profile's `metadatas` field (since HRFlow profiles do not support custom file attachments). Each document is individually scored by the LLM to produce a `delta` contribution to the candidate's total score. If no extra documents are provided, grading uses only the HRFlow base score. + +--- + +## Data Model + +### Storage — HRFlow Profile Metadata + +Extra documents are stored as entries in the profile's `metadatas` array via `PUT /v1/profile/indexing`. Each entry represents one document submitted for a specific job. + +```json +{ + "metadatas": [ + { + "name": "extra_doc_{job_key}_{timestamp}", + "value": "{\"job_key\": \"abc123\", \"filename\": \"interview_notes.txt\", \"content\": \"Candidate demonstrated strong problem-solving...\", \"uploaded_by\": \"hr@company.com\", \"uploaded_at\": \"2026-03-28T14:00:00Z\", \"delta\": 0.08, \"delta_rationale\": \"Reveals strong leadership experience relevant to the role.\"}" + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Namespaced key: `extra_doc_{job_key}_{unix_timestamp}` | +| `value` | JSON string | Serialized document object (see below) | + +### Document Object + +```json +{ + "job_key": "abc123", + "filename": "interview_notes.txt", + "content": "Full text content of the document...", + "uploaded_by": "hr@company.com", + "uploaded_at": "2026-03-28T14:00:00Z", + "delta": 0.08, + "delta_rationale": "Reveals strong leadership experience relevant to the role." +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `job_key` | string | yes | Scopes the document to a specific job application | +| `filename` | string | yes | Display name shown in the chat UI | +| `content` | string | yes | Full text content | +| `uploaded_by` | string | no | Email or identifier of the HR user who submitted | +| `uploaded_at` | ISO 8601 | yes | Submission timestamp | +| `delta` | float | no | LLM-assigned score contribution (−0.2 to +0.2). `null` until first grade runs. | +| `delta_rationale` | string | no | One-sentence LLM explanation of the delta. `null` until first grade runs. | + +### Constraints + +- **Per-job scoping** — documents for job A are not visible when reviewing job B. +- **Multiple documents** — multiple documents per (candidate, job) pair are supported. +- **Size limit** — content truncated at 8 000 characters to stay within HRFlow metadata value limits. +- **No deletion** in v1 — documents are append-only. +- **Stable deltas** — once a document's `delta` is written, it is never re-scored. Only documents with `delta = null` trigger LLM calls on the next grade. + +--- + +## API + +### List extra documents for a candidate on a job + +``` +GET /api/candidates/{profile_key}/documents?job_key={job_key} +``` + +**Response:** +```json +{ + "documents": [ + { + "id": "extra_doc_abc123_1711634400", + "filename": "interview_notes.txt", + "content": "Candidate demonstrated...", + "uploaded_by": "hr@company.com", + "uploaded_at": "2026-03-28T14:00:00Z", + "delta": 0.08, + "delta_rationale": "Reveals strong leadership experience." + } + ] +} +``` + +Implementation: fetch profile via `GET /v1/profile/indexing`, filter `metadatas` entries whose `name` starts with `extra_doc_{job_key}_`, parse each value, sort by `uploaded_at`. + +--- + +### Upload a new text document + +``` +POST /api/candidates/{profile_key}/documents +``` + +**Request body:** +```json +{ + "job_key": "abc123", + "filename": "interview_notes.txt", + "content": "Full text content..." +} +``` + +**Response:** +```json +{ + "ok": true, + "id": "extra_doc_abc123_1711634400" +} +``` + +Implementation: +1. Fetch current profile +2. Parse existing `metadatas` +3. Append new entry with `name = extra_doc_{job_key}_{unix_timestamp}` (no `delta` yet) +4. `PUT /v1/profile/indexing` with updated metadatas + +--- + +### Upload a file (PDF / DOCX / Audio) + +``` +POST /api/candidates/{profile_key}/documents/file +``` + +**Request body** — `multipart/form-data`: + +| Field | Type | Description | +|-------|------|-------------| +| `job_key` | string | Job the document is scoped to | +| `file` | binary | The file to process | + +**Accepted formats:** + +| Extension | Processing | +|-----------|-----------| +| `.pdf` | Text extracted via `pypdf` (`PdfReader`) | +| `.docx` / `.doc` | Text extracted via `python-docx` (paragraphs joined by newline) | +| `.mp3` / `.m4a` / `.wav` / `.aac` / `.ogg` / `.flac` / `.aiff` | Transcribed via LLM multimodal API (`google/gemini-2.0-flash-001`) | +| `.txt` and other text | Decoded as UTF-8 | + +**Response:** +```json +{ + "ok": true, + "id": "extra_doc_abc123_1711634400", + "content": "" +} +``` + +**Errors:** +- `400` — unsupported format or no text could be extracted +- `502` — upstream error (HRFlow, LLM) + +The extracted/transcribed text is stored as the document's `content` field. The filename is preserved as-is for display. After this endpoint returns the frontend re-fetches the document list and triggers grading. + +--- + +## Scoring Integration + +### Per-Document Delta Scoring + +Each document is scored individually by the LLM in context of all other attached documents. Scoring happens inside `POST /api/ai/grade`. + +```python +already_scored = [d for d in extra_docs if d.get("delta") is not None] +to_score = [d for d in extra_docs if d.get("delta") is None] + +for doc in to_score: + other_docs = [d for d in extra_docs if d["id"] != doc["id"]] + result = await llm.score_single_document(job, profile, doc, other_docs) + # result = {"delta": float, "rationale": str} + +# write delta + delta_rationale back into each document's metadata entry +await hrflow.update_documents_with_deltas(profile_key, job_key, newly_scored) + +all_deltas = [d["delta"] for d in already_scored + newly_scored] +ai_adjustment = round(max(-0.3, min(0.3, sum(all_deltas))), 3) +``` + +### Delta Scoring Rules + +| Delta | Meaning | +|-------|---------| +| +0.01 to +0.2 | Document reveals genuine strengths or achievements that support the candidate's fit | +| ~0.0 | Neutral, redundant, or doesn't add new signal | +| -0.01 to -0.2 | Explicit red flag, or directly contradicts a positive claim made in another document | + +> A document that is simply "less impressive" than another is **not** a contradiction. Only genuine factual contradictions or explicit red flags produce a negative delta. + +### Score Formula + +``` +ai_adjustment = sum(all document deltas), capped at ±0.3 +total = min(1.0, max(0.0, base_score + ai_adjustment + bonus)) +``` + +--- + +## UI + +### Location + +The extra documents panel lives as the **"Documents"** tab in `CandidatePanel`, alongside Overview / Synthesis / Scoring / Resume / Ask. + +--- + +### Document Bubbles + +Each submitted document is displayed as a chat bubble anchored to the right: + +``` + ┌─────────────────────────┐ + │ 📄 interview_notes.txt +8%│ + │ Reveals strong leadership │ + │ ──────────────────────── │ + │ Candidate demonstrated │ + │ strong problem-solving… │ + │ [View full text ›] │ + │ │ + │ hr@company · 28 Mar 14:00 │ + └─────────────────────────┘ +``` + +**Delta badge** — colored pill next to the filename: +- Green background: positive delta (`+X%`) +- Red background: negative delta (`-X%`) +- Neutral: zero delta +- Not shown: `delta = null` (document not yet graded) + +**Delta rationale** — one-sentence LLM explanation shown in italic below the filename. + +--- + +### Text Viewer Panel + +Clicking **"View full text ›"** opens an overlay panel showing the full document content in a scrollable monospace view. Close button dismisses it. + +--- + +### Auto-Grade on Send + +When a document is submitted: +1. Document is uploaded immediately +2. "Grading…" spinner appears on the candidate row (via `processingProfiles`) and in the panel banner +3. Grade runs in background — only the new document is scored; existing deltas are unchanged +4. Score display updates (Phase 1 complete) +5. "Generating synthesis…" banner appears +6. Synthesis runs in background (Phase 2 complete) +7. Processing cleared + +--- + +## Component Structure + +``` +CandidatePanel +└── DocumentsTab + ├── DocumentBubble[] (one per document) + │ ├── DeltaBadge (colored +X% / -X% pill) + │ ├── delta_rationale (italic one-liner below filename) + │ ├── content preview (first 2 lines / 120 chars) + │ └── onClick → TextViewerPanel overlay + └── DocumentInput + ├── FilenameField (optional, for text entry) + ├── ContentTextarea (6 rows, Ctrl+Enter to send) + ├── UploadFileButton (📎 hidden , triggers handleFileChange) + └── SendButton (text path only) +``` + +--- + +## State & Loading + +| State | Trigger | Behaviour | +|-------|---------|-----------| +| Loading documents | Tab opened | Spinner while fetching, then list renders | +| Sending text document | Send clicked | Button disabled; on success new bubble appended; auto-grade fires | +| Uploading file | File selected | `"Processing file…"` status shown; after extraction → auto-grade fires | +| Send/upload error | API error | Error message below input, input remains editable | +| Grading | After send/upload | `processingProfiles[profileKey] = 'Grading…'` — spinner on candidate row + panel banner | +| Synthesis | After grade | `processingProfiles[profileKey] = 'Generating synthesis…'` — banner updates | +| Viewer open | Bubble click | TextViewerPanel renders as overlay | +| Viewer closed | Close button | TextViewerPanel unmounts | + +--- + +## Out of Scope (v1) + +- Deletion or editing of submitted documents. +- Notifications to HR when a document is added by another user. +- Versioning or diff views. +- Structured data extraction from documents (OCR, advanced parsing beyond pypdf/python-docx). diff --git a/HEpiR-HREvolution/docs/frontend-components.md b/HEpiR-HREvolution/docs/frontend-components.md new file mode 100644 index 0000000..5c28495 --- /dev/null +++ b/HEpiR-HREvolution/docs/frontend-components.md @@ -0,0 +1,315 @@ +# Frontend Components + +## Design System + +Slack-dark sidebar + Jira-light content area. All styles are inline CSS-in-JS objects defined at the top of each component file. Global tokens in `src/index.css`. + +**Key CSS variables:** +- `--sidebar-bg: #1a1d21` — dark sidebar +- `--accent: #1264a3` — Slack blue +- `--bg: #f8f8f8` — main content background +- `--surface: #ffffff` — cards/panels +- `--border: #e0e0e0` + +**Score badge classes:** `.score-badge.high` (≥70% green), `.score-badge.mid` (≥45% yellow), `.score-badge.low` (<45% red), `.score-badge.none` (unscored, grey). + +--- + +## Layout + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Sidebar (260px fixed) │ JobView (flex: 1, minWidth: 0) │ +│ │ │ +│ [HRFlow logo] │ [Job title] [Search] [Stage] [+ Add] │ +│ [⌕ Search jobs] │ ─────────────────────────────────── │ +│ │ # │ Candidate │ Stage │ Score │ Bonus │ +│ JOBS │ 1 │ Alice M. │ Interv│ 83% │ +5% │ +│ · Job 1 │ 2 │ Bob D. │ Screen│ 74% │ — │ +│ · Job 2 │ 3 │ … │ … │ — │ — │ +│ ─────────────────── │ │ +│ 💼 Create job │ │ +│ ───────────────────── │ │ +│ [👤 HR Manager] │ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +`#root` is `width: 100%; height: 100dvh` (no `display: flex`). +`DashboardPage` renders `display: flex; height: 100dvh`. +`JobView` has `flex: 1; minWidth: 0` to fill remaining width without overflow. + +--- + +## DashboardPage + +**File:** `src/pages/DashboardPage.jsx` + +Root page. Manages job list, selected job/candidate state, and the shared processing-indicator state. + +**localStorage pending job pattern:** +```js +const LS_KEY = 'hrflow_pending_job_keys' +export function registerPendingJob(key) { ... } +async function fetchJobs() { + // 1. GET /api/jobs → main list + // 2. for pending keys not in results → GET /api/jobs/{key} individually + // 3. clean keys that now appear in search results +} +``` + +**Processing state:** +```js +const [processingProfiles, setProcessingProfiles] = useState({}) +const [candidateRefreshKey, setCandidateRefreshKey] = useState(0) + +function setProcessing(profileKey, status) { + setProcessingProfiles((prev) => + status ? { ...prev, [profileKey]: status } + : Object.fromEntries(Object.entries(prev).filter(([k]) => k !== profileKey)) + ) + // When status clears, increment refreshKey so JobView re-fetches updated scores + if (!status) setCandidateRefreshKey((k) => k + 1) +} +``` + +`processingProfiles` is a `{ [profileKey]: statusLabel }` map shared between `JobView` (shows spinner on candidate row) and `CandidatePanel` (shows banner). `setProcessing` is the single mutator passed to both. + +**Props passed down:** +- `Sidebar`: `jobs`, `selectedJobKey`, `onSelectJob`, `loading`, `onDataChanged` +- `JobView`: `job`, `onSelectCandidate`, `processingProfiles`, `refreshKey`, `selectedProfileKey`, `onCandidateRefreshed`, `onProcessingChange` +- `CandidatePanel`: `candidateRef`, `job`, `onClose`, `onProcessingChange`, `processingStatus` + +--- + +## Sidebar + +**File:** `src/components/Sidebar.jsx` + +Dark 260px left panel. Shows job list with search filter. + +**Actions:** +- Job items → `onSelectJob(job)` +- Bottom action "💼 Create job" → opens `CreateJobModal` + +**No longer contains:** "Add candidate" button (moved to JobView toolbar). + +--- + +## JobView + +**File:** `src/components/JobView.jsx` + +Central panel. Shows ranked candidate table for selected job. + +**Props:** `job`, `onSelectCandidate`, `processingProfiles`, `refreshKey`, `selectedProfileKey`, `onCandidateRefreshed`, `onProcessingChange` + +**Toolbar:** job title, candidate search input, stage filter dropdown, "📎 Add candidate" button. + +**Processing indicator** — visible per row below the candidate name: +```jsx +{processingProfiles[c.profile_key] && ( +
+
+ {processingProfiles[c.profile_key]} {/* e.g. "Grading…" or "Generating synthesis…" */} +
+)} +``` + +**Candidate list refresh** — `refreshKey` prop triggers `fetchCandidates` via `useEffect`. Incremented by `DashboardPage.setProcessing` whenever processing ends, so scores update in the list automatically. + +**`onCandidateRefreshed`** — after re-fetch, the currently selected candidate's object is synced in `DashboardPage` without re-mounting `CandidatePanel`. Uses a `useRef` to avoid becoming a `useCallback` dependency. + +**Pending candidates localStorage pattern** (mirrors job pending pattern): +```js +function lsKey(jobKey) { return `hrflow_pending_candidates_${jobKey}` } +export function registerPendingCandidate(jobKey, profileKey) { ... } +// fetchCandidates: for pending keys not in tracking list → getCandidate(key) individually +``` + +**Non-blocking upload + background grade flow** (triggered by `UploadResumeModal.onSuccess`): +```js +onSuccess={(data) => { + setShowUpload(false) + if (data?.profile_key) registerPendingCandidate(job.key, data.profile_key) + fetchCandidates() + if (data?.profile_key && onProcessingChange) { + const profileKey = data.profile_key + onProcessingChange(profileKey, 'Grading…') + ;(async () => { + try { + await gradeCandidate(job.key, profileKey) + onProcessingChange(profileKey, 'Generating synthesis…') + await synthesizeCandidate(job.key, profileKey) + } catch (e) { ... } finally { + onProcessingChange(profileKey, null) + } + })() + } +}} +``` + +**Exports:** `registerPendingCandidate` (used internally in `onSuccess`) + +--- + +## CandidatePanel + +**File:** `src/components/CandidatePanel.jsx` + +Right drawer (700px). Opens on candidate row click. + +**Props:** `candidateRef`, `job`, `onClose`, `onProcessingChange`, `processingStatus` + +**Header:** avatar (photo or initials), full name, email, score badge, verdict chip. + +**Pipeline progress bar:** Applied → Screening → Interview → Offer → Hired (filled up to current stage). + +**Processing banner** — between pipeline bar and tabs, visible regardless of active tab: +```jsx +{(loadingSynth || processingStatus) && ( +
+
+ {loadingSynth ? 'Generating synthesis…' : processingStatus} +
+)} +``` + +**Tabs:** + +| Tab | Content | +|-----|---------| +| Overview | Skills chips, experience cards, education cards | +| Synthesis | LLM summary, strengths/weaknesses chips (green/red), upskilling chips (yellow) | +| Scoring | Score breakdown grid (HRFlow Score / AI Adjustment / HR Bonus / Total), HR bonus input | +| Documents | Per-document chat bubbles with delta badges; text input to add documents | +| Resume | Embedded PDF via `