Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions HEpiR-HREvolution/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

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
### Key capabilities

- **Ranked candidate list** — candidates are automatically 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 to the total score.
- **🎙️ Voice Recording** — record interview notes directly in the browser with automatic AI transcription (powered by Gemini 2.0 Flash).
- **💬 Interview Question Generator** — generate tailored Technical, Behavioral, and Motivation questions based on the candidate's profile and all attached documents.
- **✉️ AI Email Generation** — draft personalized recruitment emails (interviews, follow-ups, rejections) using candidate context, with direct "Open in Gmail" integration.
- **Recruitment pipeline** — customisable stages per job (Screening, Interview, Technical Test, …) with real-time stage tracking and manual score adjustments.

## HrFlow.ai APIs used

Expand All @@ -29,6 +30,18 @@ Key capabilities:
| `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 |

## Tech Stack

| Layer | Technology |
|-------|------------|
| **Frontend** | React 18, Vite 5, Vanilla CSS |
| **Backend** | Python 3.12, FastAPI |
| **AI (Grading/Synthesis)** | OpenRouter (configurable model) |
| **AI (Transcription)** | Google Gemini 2.0 Flash |
| **Parsing** | `pypdf`, `python-docx` |
| **HR Data** | HrFlow.ai API v1 |
| **Infra** | Docker & Docker Compose |

## How to run

### Prerequisites
Expand Down Expand Up @@ -72,18 +85,20 @@ docker compose up --build
```
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
├── routers/
│ ├── jobs.py job CRUD + stage pipeline
│ ├── candidates.py profile, email generation, file upload
│ ├── ai.py grading, synthesis, transcription, questions
│ └── webhooks.py incoming email parsing & auto-matching
└── services/
├── hrflow.py HrFlow API client
└── llm.py OpenRouter LLM calls
├── 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.
No local database — HrFlow is the single source of truth. Scores, synthesis, and extra documents are stored directly in profile tags and metadata. An in-memory cache layer is used to bridge HrFlow's indexing delay.

## Team

- **Adrien CAPITAINE** — Developer
- **Nathan CHAMPAGNE** — Developer
- **Joris BELY** — Developer
- **Joris BELY** — Developer
Binary file added HEpiR-HREvolution/assets/Demo_HRevolution.mp4
Binary file not shown.
Binary file modified HEpiR-HREvolution/assets/preview.png
100644 → 100755
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added HEpiR-HREvolution/assets/preview2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 63 additions & 10 deletions HEpiR-HREvolution/backend/routers/ai.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""AI router — grading, synthesis, and interview question generation."""

import json
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel
from services import hrflow, llm

Expand All @@ -23,6 +23,17 @@ class AskRequest(BaseModel):
profile_key: str


@router.post("/transcribe")
async def transcribe_audio(file: UploadFile = File(...)):
"""Transcribe an audio file and return the text without saving anything."""
try:
content = await file.read()
text = await llm.transcribe_audio(content, file.filename)
return {"text": text}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))


@router.post("/grade")
async def grade_candidate(req: GradeRequest):
"""
Expand All @@ -33,12 +44,15 @@ async def grade_candidate(req: GradeRequest):
"""
try:
job = await hrflow.get_job(req.job_key)
profile = await hrflow.get_profile(req.profile_key)
profile = await hrflow.get_profile(req.profile_key, use_cache=False)

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)

existing_synth_raw = hrflow.extract_tag(profile, f"synthesis_{req.job_key}")
synthesis_data = json.loads(existing_synth_raw) if existing_synth_raw else None

# 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")
Expand All @@ -62,15 +76,25 @@ async def grade_candidate(req: GradeRequest):
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"]})
current_ai_adj = sum([d.get("delta", 0) for d in already_scored]) + sum([d.get("delta", 0) for d in newly_scored])
current_total_score = min(1.0, max(0.0, base_score + current_ai_adj))
score_result = await llm.score_single_document(job, profile, doc, other_docs, synthesis_data, current_total_score)
newly_scored.append({**doc, "delta": score_result["delta"], "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)
ai_adjustment = round(sum(all_deltas), 3)
# Build complete document list in memory — avoids HRFlow indexing latency on re-fetch
newly_by_id = {d["id"]: d for d in newly_scored}
scored_documents = [
{**d, "delta": newly_by_id[d["id"]]["delta"], "delta_rationale": newly_by_id[d["id"]]["delta_rationale"]}
if d["id"] in newly_by_id else d
for d in extra_docs
]
else:
ai_adjustment = 0.0
scored_documents = []
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
Expand All @@ -82,10 +106,12 @@ async def grade_candidate(req: GradeRequest):
"ai_adjustment": ai_adjustment,
"bonus": existing.get("bonus", 0.0),
}))
hrflow._invalidate_job_candidates(req.job_key)

return {
"base_score": base_score,
"ai_adjustment": ai_adjustment,
"documents": scored_documents,
}
except Exception as e:
print(f"grade error: {e}", flush=True)
Expand Down Expand Up @@ -119,11 +145,38 @@ async def synthesize_candidate(req: SynthesizeRequest):
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
existing_synth_raw = hrflow.extract_tag(profile, f"synthesis_{req.job_key}")
existing_synthesis = None
if existing_synth_raw:
try:
existing_synthesis = json.loads(existing_synth_raw)
except Exception:
pass

synthesis = None
last_err = None
for attempt in range(2):
try:
synthesis = await llm.synthesize_candidate(
job, profile, tracking or {}, upskilling, final_score, extra_docs, existing_synthesis
)
if synthesis and isinstance(synthesis, dict) and synthesis.get("summary"):
break
except Exception as e:
last_err = e
print(f"[synthesize] attempt {attempt+1} failed: {e}", flush=True)

if synthesis and isinstance(synthesis, dict) and synthesis.get("summary"):
await _patch_tag(req.profile_key, profile, f"synthesis_{req.job_key}", json.dumps(synthesis))
return synthesis

# Fallback to existing synthesis if generation failed
existing_synth_raw = hrflow.extract_tag(profile, f"synthesis_{req.job_key}")
if existing_synth_raw:
print("[synthesize] generation failed, falling back to existing synthesis", flush=True)
return json.loads(existing_synth_raw)

raise last_err or Exception("Synthesis generation failed and no existing synthesis found")
except Exception as e:
print(f"synthesize error: {e}", flush=True)
raise HTTPException(status_code=502, detail=str(e))
Expand Down
36 changes: 34 additions & 2 deletions HEpiR-HREvolution/backend/routers/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import io
import json
import re
from urllib import response
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from services import hrflow, llm
Expand Down Expand Up @@ -33,6 +35,33 @@ class StagePayload(BaseModel):
stage: str


@router.post("/{profile_key}/email/generate")
async def generate_candidate_email(profile_key: str, job_key: str, guidelines: str = None):
"""Generate a personalized recruitment email using AI."""
try:
profile = await hrflow.get_profile(profile_key)
# Fetch job and synthesis...
jobs = await hrflow.list_jobs()
job = next((j for j in jobs if j.get("key") == job_key), {"name": "Recruitment Opportunity"})

# Fetch synthesis if it exists
synthesis = None
raw_synth = hrflow.extract_tag(profile, f"synthesis_{job_key}")
if raw_synth:
try:
synthesis = json.loads(raw_synth)
except:
pass

# Fetch extra documents
extra_docs = hrflow.get_extra_documents(profile, job_key)

email_content = await llm.generate_email(job, profile, synthesis, guidelines, extra_docs)
return email_content
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))


@router.patch("/{profile_key}/stage")
async def update_candidate_stage(profile_key: str, payload: StagePayload):
"""Update candidate recruitment stage for a specific job."""
Expand Down Expand Up @@ -169,12 +198,15 @@ async def add_document_file(

if ext == "pdf":
reader = PdfReader(io.BytesIO(content))
extracted_text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
raw_text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
cleaned = re.sub(r'(?<![.!?:;])\s*\n\s*', ' ', raw_text)
cleaned = re.sub(r'\s*\n\s*', '\n\n', cleaned)
extracted_text = re.sub(r'[ \t]+', ' ', cleaned).strip()
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"]:
elif ext in ["mp3", "m4a", "wav", "aac", "ogg", "flac", "aiff", "webm"]:
extracted_text = await llm.transcribe_audio(content, filename)
else:
# Fallback for plain text files
Expand Down
100 changes: 93 additions & 7 deletions HEpiR-HREvolution/backend/routers/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,82 @@ async def create_job(payload: JobCreatePayload):
raise HTTPException(status_code=502, detail=str(e))


@router.get("/init")
async def get_init_data():
"""Bulk initialization: returns jobs, trackings, and profiles with tags."""
try:
import asyncio
# Parallel fetch for everything
jobs_task = hrflow.list_jobs(use_cache=False)
trackings_task = hrflow.list_all_trackings()
profiles_task = hrflow.list_all_profiles(limit=300) # Get last 300 profiles with tags

jobs, trackings, profiles = await asyncio.gather(jobs_task, trackings_task, profiles_task)

# Populate candidate list cache in background
# We can reconstruct what get_job_candidates would return
profiles_map = {p["key"]: p for p in profiles if p.get("key")}
candidates_by_job = {}
for t in trackings:
jk = t.get("job_key") or t.get("job", {}).get("key")
pk = t.get("profile_key") or t.get("profile", {}).get("key")
if not jk or not pk: continue

if jk not in candidates_by_job: candidates_by_job[jk] = []

p = profiles_map.get(pk)
info = p.get("info", {}) if p else t.get("profile", {}).get("info", {})

base_score = None
ai_adjustment = 0.0
bonus = 0.0
stage = t.get("stage") or "applied"

if p:
score_tag = hrflow.extract_tag(p, f"job_data_{jk}")
if score_tag:
try:
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)
except: pass
stage_tag = hrflow.extract_tag(p, f"stage_{jk}")
if stage_tag:
try: stage = json.loads(stage_tag).get("stage", stage)
except: pass

score = (base_score + ai_adjustment) if base_score is not None else None
candidates_by_job[jk].append({
"profile_key": pk,
"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,
"tracking_key": t.get("key", ""),
})

for jk, cands in candidates_by_job.items():
cands.sort(key=lambda c: (c["score"] is not None, c["score"] or 0), reverse=True)
# We don't set the cache here anymore because data might be incomplete (missing profile pictures/names)
# hrflow._set_cached(f"job_candidates_{jk}", cands)

return {
"jobs": jobs,
"trackings": trackings,
"profiles": profiles
}
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=502, detail=str(e))


@router.get("/debug-raw")
async def debug_raw_jobs():
"""Return the raw HRFlow response for debugging."""
Expand Down Expand Up @@ -253,6 +329,11 @@ 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.
"""
cache_key = f"job_candidates_{job_key}"
cached = hrflow._get_cached(cache_key)
if cached:
return {"candidates": cached}

try:
trackings = await hrflow.list_trackings(job_key)
except Exception as e:
Expand All @@ -277,17 +358,21 @@ async def get_job_candidates(job_key: str):
# 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)
try:
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)
except: pass

# 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")
try:
s_data = json.loads(stage_tag)
stage = s_data.get("stage", "applied")
stage_updated_at = s_data.get("updated_at")
except: pass
else:
# Fallback to tracking stage
stage = tracking.get("stage") or "applied"
Expand Down Expand Up @@ -317,4 +402,5 @@ async def get_job_candidates(job_key: str):

# Sort: scored candidates first (desc), unscored last
candidates.sort(key=lambda c: (c["score"] is not None, c["score"] or 0), reverse=True)
hrflow._set_cached(cache_key, candidates)
return {"candidates": candidates}
Loading
Loading