diff --git a/HEpiR-HREvolution/README.md b/HEpiR-HREvolution/README.md index c2875eb..c8f8e49 100644 --- a/HEpiR-HREvolution/README.md +++ b/HEpiR-HREvolution/README.md @@ -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 @@ -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 @@ -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 \ No newline at end of file +- **Joris BELY** — Developer diff --git a/HEpiR-HREvolution/assets/Demo_HRevolution.mp4 b/HEpiR-HREvolution/assets/Demo_HRevolution.mp4 new file mode 100755 index 0000000..2fef1cd Binary files /dev/null and b/HEpiR-HREvolution/assets/Demo_HRevolution.mp4 differ diff --git a/HEpiR-HREvolution/assets/preview.png b/HEpiR-HREvolution/assets/preview.png old mode 100644 new mode 100755 index ad67add..d5027a3 Binary files a/HEpiR-HREvolution/assets/preview.png and b/HEpiR-HREvolution/assets/preview.png differ diff --git a/HEpiR-HREvolution/assets/preview2.png b/HEpiR-HREvolution/assets/preview2.png new file mode 100755 index 0000000..acdcd22 Binary files /dev/null and b/HEpiR-HREvolution/assets/preview2.png differ diff --git a/HEpiR-HREvolution/backend/routers/ai.py b/HEpiR-HREvolution/backend/routers/ai.py index 7b4f885..9343893 100644 --- a/HEpiR-HREvolution/backend/routers/ai.py +++ b/HEpiR-HREvolution/backend/routers/ai.py @@ -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 @@ -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): """ @@ -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") @@ -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 @@ -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) @@ -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)) diff --git a/HEpiR-HREvolution/backend/routers/candidates.py b/HEpiR-HREvolution/backend/routers/candidates.py index 4b914c6..5a8035c 100644 --- a/HEpiR-HREvolution/backend/routers/candidates.py +++ b/HEpiR-HREvolution/backend/routers/candidates.py @@ -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 @@ -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.""" @@ -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'(? highest_score: + highest_score = score + best_job_key = job_key + + # 4. Link candidate to the best job (or first one if no scores yet) + target_job_key = best_job_key or jobs[0]["key"] + + logger.info(f"Linking candidate {profile_key} to job {target_job_key} (score: {highest_score})") + await hrflow.create_tracking(target_job_key, profile_key, stage="applied") + + return { + "ok": True, + "profile_key": profile_key, + "job_key": target_job_key, + "score": highest_score + } + + except Exception as e: + logger.error(f"Error processing email webhook: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/HEpiR-HREvolution/backend/services/hrflow.py b/HEpiR-HREvolution/backend/services/hrflow.py index d86ddc5..ccd6efb 100644 --- a/HEpiR-HREvolution/backend/services/hrflow.py +++ b/HEpiR-HREvolution/backend/services/hrflow.py @@ -8,6 +8,21 @@ BASE_URL = "https://api.hrflow.ai/v1" +_CACHE: dict = {} + + +def _get_cached(key: str): + return _CACHE.get(key) + + +def _set_cached(key: str, value) -> None: + _CACHE[key] = value + + +def _invalidate_job_candidates(job_key: str) -> None: + """Evict the candidate list cache for a specific job.""" + _CACHE.pop(f"job_candidates_{job_key}", None) + def _headers() -> dict: return { @@ -20,7 +35,7 @@ def _headers() -> dict: # Jobs # --------------------------------------------------------------------------- -async def list_jobs(limit: int = 30, page: int = 1) -> list[dict]: +async def list_jobs(limit: int = 30, page: int = 1, use_cache: bool = True) -> list[dict]: """Return jobs from the configured board using the searching endpoint.""" async with httpx.AsyncClient() as client: r = await client.get( @@ -108,7 +123,7 @@ async def patch_job_tags(job_key: str, tags: list[dict]) -> dict: # Profiles # --------------------------------------------------------------------------- -async def get_profile(profile_key: str) -> dict: +async def get_profile(profile_key: str, use_cache: bool = True) -> dict: """Return a candidate profile by key.""" async with httpx.AsyncClient() as client: r = await client.get( @@ -142,7 +157,8 @@ async def patch_profile_tags(profile_key: str, tags: list[dict]) -> dict: timeout=15, ) r.raise_for_status() - return r.json().get("data", {}) + res = r.json().get("data", {}) + return res # --------------------------------------------------------------------------- @@ -193,6 +209,40 @@ async def get_tracking(job_key: str, profile_key: str) -> dict | None: return None +async def list_all_trackings() -> list[dict]: + """Return all trackings across all jobs in the configured board.""" + import asyncio + jobs = await list_jobs() + job_keys = [j["key"] for j in jobs if j.get("key")] + results = await asyncio.gather(*[list_trackings(jk) for jk in job_keys], return_exceptions=True) + all_trackings = [] + for r in results: + if isinstance(r, list): + all_trackings.extend(r) + return all_trackings + + +async def list_all_profiles(limit: int = 100) -> list[dict]: + """Return profiles from the configured source.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{BASE_URL}/profiles/searching", + headers=_headers(), + params={ + "source_keys": f'["{settings.hrflow_source_key}"]', + "query": "", + "limit": limit, + "page": 1, + }, + timeout=20, + ) + if not r.is_success: + print(f"list_all_profiles → {r.status_code}: {r.text}", flush=True) + return [] + data = r.json() + return (data.get("data") or {}).get("profiles", []) + + # --------------------------------------------------------------------------- # Scoring (HRFlow native) # --------------------------------------------------------------------------- @@ -337,7 +387,8 @@ async def patch_profile_metadatas(profile_key: str, metadatas: list[dict]) -> di timeout=15, ) r.raise_for_status() - return r.json().get("data", {}) + res = r.json().get("data", {}) + return res def get_extra_documents(profile: dict, job_key: str) -> list[dict]: @@ -393,7 +444,7 @@ async def update_documents_with_deltas(profile_key: str, job_key: str, scored_do 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", "") + doc_data["delta_rationale"] = scored_map[name].get("delta_rationale", "") meta = {"name": name, "value": _json.dumps(doc_data)} except Exception: pass @@ -419,17 +470,17 @@ def build_job_tag(job_key: str, score: float, bonus: float = 0.0, base_score: fl # --------------------------------------------------------------------------- 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}, + {"key": "applied", "label": "Candidature", "color": "gray", "order": 0, "builtin": True}, + {"key": "hired", "label": "Recruté", "color": "green", "order": 999, "builtin": True}, + {"key": "rejected", "label": "Rejeté", "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"}, + {"key": "screening", "label": "Présélection", "color": "blue"}, + {"key": "interview", "label": "Entretien", "color": "indigo"}, + {"key": "technical_test", "label": "Test technique", "color": "purple"}, + {"key": "offer", "label": "Offre envoyée", "color": "orange"}, ] async def get_job_stages(job_key: str) -> list[dict]: @@ -489,4 +540,5 @@ async def update_candidate_stage(profile_key: str, job_key: str, stage: str) -> "value": json.dumps({"job_key": job_key, "stage": stage, "updated_at": updated_at}) } await patch_profile_tags(profile_key, existing_tags + [new_tag]) + _invalidate_job_candidates(job_key) return {"stage": stage, "updated_at": updated_at} diff --git a/HEpiR-HREvolution/backend/services/llm.py b/HEpiR-HREvolution/backend/services/llm.py index 060c101..76829c7 100644 --- a/HEpiR-HREvolution/backend/services/llm.py +++ b/HEpiR-HREvolution/backend/services/llm.py @@ -2,6 +2,7 @@ import base64 import json +import re from openai import AsyncOpenAI from config import settings @@ -39,13 +40,13 @@ async def transcribe_audio(audio_bytes: bytes, filename: str) -> str: # 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"]: + if fmt not in ["mp3", "m4a", "wav", "aac", "ogg", "flac", "aiff", "webm"]: 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" + model = "mistralai/voxtral-small-24b-2507" response = await client.chat.completions.create( model=model, @@ -53,7 +54,16 @@ async def transcribe_audio(audio_bytes: bytes, filename: str) -> str: { "role": "user", "content": [ - {"type": "text", "text": "Please provide a clean transcription of this audio file. Output only the transcript text."}, + {"type": "text", "text": + ( + "Tu es un transcripteur de haute précision. " + "Transcris fidèlement cet enregistrement audio en FRANÇAIS. " + "REGLE CRITIQUE : Si l'audio est silencieux, ne contient que du bruit, " + "ou n'a pas de parole humaine intelligible, réponds par : '---SILENCE---'. " + "Ne génère JAMAIS de texte de remplissage ou d'exemple. " + "Ne renvoie que le texte transcrit ou le mot-clé, sans commentaire, sans markdown et sans introduction." + ) + }, { "type": "input_audio", "input_audio": { @@ -67,64 +77,102 @@ async def transcribe_audio(audio_bytes: bytes, filename: str) -> str: ) return response.choices[0].message.content.strip() +def _parse_json(raw: str): + """ + Nettoie la réponse de l'IA (enlève le markdown ```json) + et extrait le bloc JSON pur. + """ + # Cherche tout ce qui est entre le premier { ou [ et le dernier } ou ] + match = re.search(r'(\{.*\}|\[.*\])', raw, re.DOTALL) + clean_str = match.group(1) if match else raw + return json.loads(clean_str) # --------------------------------------------------------------------------- # Per-document scoring # --------------------------------------------------------------------------- + DOCUMENT_SCORE_SYSTEM = """You are an expert HR evaluator scoring a single supplementary document attached to a candidate profile. +DO NOT evaluate the candidate's whole profile. you are ONLY scoring whether THE DOCUMENT_TO_SCORE brings "good news" or "bad news". + 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) +- The Job Requirements (Title, Summary, Skills) +- The candidate's CV/Profile claims +- The Current Synthesis (Known Strengths & Weaknesses) +- All other already-attached documents +- The candidate's CURRENT TOTAL SCORE +- The SINGLE NEW DOCUMENT to score 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). +- POSITIVE delta (+0.01 to +0.2): The document proves the candidate possesses a skill REQUIRED BY THE JOB, demonstrates a new strength, OR overcomes a previously identified weakness. +- NEAR ZERO (0.0): The document is neutral, irrelevant to the job, redundant, or doesn't add meaningful new signal. +- NEGATIVE delta (-0.01 to -0.2): The document contains an explicit new red flag, OR proves the candidate FAILS at a skill required by the job, OR proves a "Strength" from the synthesis/CV is actually false. + +CRITICAL RULE: DIMINISHING RETURNS FOR HIGH SCORES +- You will receive the "candidate_current_score" (a float between 0.0 and 1.0). +- If the score is ALREADY VERY HIGH (e.g., above 0.85 or 85%), you MUST BE EXTREMELY HARSH AND CONSERVATIVE. +- The remaining points to reach 100% represent absolute perfection. If the score is already 90% or 95%, a normal positive document should only give +0.01 or +0.02. To give +0.05 or more at this level, the document MUST demonstrate EXCEPTIONAL, rare, or leadership-level mastery of a critical skill. +- Conversely, if the score is low (e.g., 0.40), you can be more generous (e.g., +0.10) for finding a required skill. -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. +CRITICAL RULES TO AVOID FALSE PENALTIES: +- DO NOT RE-PENALIZE KNOWN WEAKNESSES: If the current synthesis already notes a weakness, DO NOT give a negative score just because the new document doesn't mention it. +- ONLY JUDGE THE NEW TEXT: If the new document is about Python, judge it on Python. Do not deduct points for unrelated missing skills. +- OVERCOMING A WEAKNESS IS POSITIVE: If the document shows the candidate is GOOD at a previously flagged weakness, give a POSITIVE score. +- DO NOT PUNISH MISSING INFO. +- ABSENCE OF EVIDENCE IS NOT EVIDENCE OF FAILURE. -Do NOT re-evaluate the candidate against the job — HRFlow already handles that. Only assess what this specific document uniquely adds, reveals, or contradicts. +CRITICAL INSTRUCTION: LANGUAGE +- All generated text MUST be strictly in French. + +CRITICAL INSTRUCTION: OUTPUT FORMAT +- You MUST output ONLY a pure, valid JSON object. DO NOT wrap the output in markdown blocks like ```json. Respond ONLY with valid JSON: { "delta": , - "rationale": "" + "rationale": "" }""" - async def score_single_document( job: dict, profile: dict, document: dict, other_docs: list[dict], + synthesis: dict = None, + current_score: float = 0.0, ) -> dict: """Score a single supplementary document in the context of all other documents. Returns {"delta": float, "rationale": str}. """ + print("score;;;;", current_score, flush=True) 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": { + "candidate_current_score": current_score, + "job_description": { + "title": job.get("name", ""), + "summary": job.get("summary", ""), + "required_skills": [_skill_name(s) for s in job.get("skills", [])], + }, + "candidate_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", ""), - }, + "current_synthesis": synthesis or {"strengths": [], "weaknesses": []}, "other_documents": [ {"filename": d.get("filename", ""), "content": d.get("content", "")} for d in other_docs ], + "document_to_score": { + "filename": document.get("filename", ""), + "content": document.get("content", ""), + }, }, ensure_ascii=False) + print(user_content, flush=True) raw = await _chat(DOCUMENT_SCORE_SYSTEM, user_content) try: - result = json.loads(raw) + result = _parse_json(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): @@ -135,37 +183,50 @@ async def score_single_document( # 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. +SYNTHESIS_SYSTEM = """You are an expert HR analyst. Your task is to evaluate a CANDIDATE'S fit for a specific job. + +CRITICAL INSTRUCTION: CANDIDATE-CENTRIC SUMMARY +- The "summary" must analyze the CANDIDATE's profile compared to the job requirements. +- Do NOT just summarize the job description. Focus entirely on why the candidate is or isn't a good fit. + +CRITICAL INSTRUCTION: MANDATORY FIELDS +- You MUST provide AT LEAST ONE strength, AT LEAST ONE weakness, and AT LEAST ONE upskilling recommendation. +- If the candidate seems to match perfectly, you must still find the weakest point, a missing "nice-to-have" skill, or an advanced area for growth. NEVER return empty arrays. + +CRITICAL INSTRUCTION: CONTINUITY & UPDATING +- IF "previous_synthesis" is EMPTY or NULL: Generate a fresh analysis. +- IF "previous_synthesis" EXISTS: + 1. Use it as your exact starting baseline. + 2. The VERY LAST document in the "extra_documents" array is the NEW evidence. + 3. Evaluate how this NEW evidence changes the baseline. + 4. Retain existing strengths/weaknesses by default. + +MANDATORY CONSISTENCY UPDATE: +- If new evidence resolves a previous weakness, you MUST REMOVE it from "weaknesses" and ADD it to "strengths". +- If new evidence contradicts a previous strength, you MUST REMOVE it from "strengths" and ADD it to "weaknesses". +- NO CONTRADICTIONS: A skill cannot appear as both a strength and a weakness. + +RULES FOR FORMATTING: +- The summary must consist of multiple sentences, not just a single sentence. +- Every item in the "strengths", "weaknesses", and "upskilling" arrays MUST be very short phrases (maximum 7 WORDS per item). DO NOT restrict characters or letters, only the number of WORDS. + +CRITICAL INSTRUCTION: LANGUAGE +- All generated text (summary, strengths, weaknesses, upskilling) MUST be written strictly in French. + +CRITICAL INSTRUCTION: OUTPUT FORMAT +- You MUST output ONLY a pure, valid JSON object. +- DO NOT include any reasoning, chain of thought, explanations, or introductory text. +- DO NOT wrap the output in markdown blocks like ```json. +- Output MUST start exactly with { and end exactly with }. + +Expected JSON schema: { - "summary": "<2-3 sentence narrative, explicitly noting any major contradictions found between the CV and extra documents>", - "strengths": ["", "", ...], - "weaknesses": ["", ...], - "upskilling": ["", ...] + "summary": "<2-3 sentence narrative IN FRENCH>", + "strengths": ["", ...], + "weaknesses": ["", ...], + "upskilling": ["", ...] }""" - async def synthesize_candidate( job: dict, profile: dict, @@ -173,14 +234,15 @@ async def synthesize_candidate( upskilling: dict, final_score: float, extra_docs: list[dict] = None, + previous_synthesis: 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", [])], + "job_title": job.get("name") or job.get("key", ""), + "job_summary": job.get("summary") or "No summary provided.", + "job_skills": [_skill_name(s) for s in job.get("skills", [])] or ["Not specified"], "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": [ @@ -199,38 +261,55 @@ async def synthesize_candidate( ], "strengths": upskilling.get("strengths", []), "weaknesses": upskilling.get("weaknesses", []), - "skill_gaps": upskilling.get("skill_gaps", []), + #"skill_gaps": upskilling.get("skill_gaps", []), + "previous_synthesis": previous_synthesis, }, ensure_ascii=False, ) + print(previous_synthesis, flush=True) + print("***", user_content, flush=True) raw = await _chat(SYNTHESIS_SYSTEM, user_content) try: - return json.loads(raw) - except json.JSONDecodeError: - return {"summary": raw, "strengths": [], "weaknesses": [], "upskilling": []} + data = _parse_json(raw) + # Ensure it's a dict and has summary + if isinstance(data, dict) and data.get("summary"): + print("___", data, flush=True) + return data + raise ValueError("Invalid synthesis format") + except (json.JSONDecodeError, ValueError): + print(f"[llm.synthesize_candidate] Failed to parse JSON. Raw output: {raw}", flush=True) + # Strip potential boxed/latex if it leaked into the fallback + clean_summary = raw.replace("\\boxed{", "").replace("\\text{", "").replace("}", "") + return {"summary": clean_summary, "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. +ASK_SYSTEM = """You are an expert interviewer. Given a job description, a candidate profile, and supplementary documents, generate targeted interview questions that probe the candidate's fit. 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. +2. USE ALL EVIDENCE: Use the candidate's CV/profile AND the extra documents to identify gaps, contradictions, or areas needing deeper investigation. 3. BE SPECIFIC: Avoid generic questions. Refer to specific skills or experiences found in the job description or candidate profile. +4. LANGUAGE: All questions MUST be written strictly in French. -Respond ONLY with valid JSON: +CATEGORIES TO PROBE (MANDATORY): +You must balance your questions across exactly these 3 categories: +- Technique: Focus on hard skills, technical stack, past project implementations, and technical problem-solving required for the job. +- Comportemental: Focus on soft skills, teamwork, handling conflicts, leadership, and how the candidate reacts in professional situations. +- Motivation: Focus on why the candidate wants this specific job, their alignment with company values, and their career goals. + +Respond ONLY with valid JSON following this strict schema: { "questions": [ - {"category": "", "question": ""}, - ... + {"category": "Technique", "question": ""}, + {"category": "Comportemental", "question": ""}, + {"category": "Motivation", "question": ""} ] }""" - def _skill_name(s) -> str: return s.get("name", "") if isinstance(s, dict) else str(s) @@ -263,6 +342,67 @@ async def generate_questions(job: dict, profile: dict, extra_docs: list[dict] = ) raw = await _chat(ASK_SYSTEM, user_content) try: - return json.loads(raw) + return _parse_json(raw) except json.JSONDecodeError: return {"questions": [{"category": "General", "question": raw}]} + + +# --------------------------------------------------------------------------- +# Email Generation +# --------------------------------------------------------------------------- + +EMAIL_SYSTEM = """You are an expert HR recruitment specialist. Your goal is to draft a personalized, professional, and engaging email to a candidate based on their profile, the job description, and specific user guidelines. + +Your email should: +1. STRICTLY FOLLOW the "user_guidelines" provided. +2. Acknowledge the candidate's specific background and why they caught your eye. +3. Briefly summarize the job opportunity. +4. Be polite, warm, and professional. +5. Be concise (under 200 words). +6. LANGUAGE: The entire email (subject and body) MUST be written strictly in French. + +Input context provided: +- Job Title & Description +- Candidate Name & Profile +- Synthesis analysis +- Extra documents +- User guidelines + +The output must be strictly valid JSON: +{ + "subject": "", + "body": "" +}""" + + +async def generate_email(job: dict, profile: dict, synthesis: dict = None, guidelines: str = None, extra_docs: list[dict] = None) -> dict: + """Generate a personalized recruitment email for a candidate.""" + user_content = json.dumps( + { + "job_title": job.get("name", ""), + "job_summary": job.get("summary", ""), + "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", []) + ], + "extra_documents": [ + { + "filename": d.get("filename", ""), + "content": d.get("content", ""), + } + for d in (extra_docs or []) + ], + "synthesis": synthesis, + "user_guidelines": guidelines, + }, + ensure_ascii=False, + ) + raw = await _chat(EMAIL_SYSTEM, user_content) + try: + return _parse_json(raw) + except json.JSONDecodeError: + return { + "subject": f"Opportunity: {job.get('name', '')}", + "body": raw + } diff --git a/HEpiR-HREvolution/docs/ai-email-generation.md b/HEpiR-HREvolution/docs/ai-email-generation.md new file mode 100644 index 0000000..744078a --- /dev/null +++ b/HEpiR-HREvolution/docs/ai-email-generation.md @@ -0,0 +1,37 @@ +# AI Email Generation Feature + +This feature allows HR recruiters to generate highly personalized recruitment emails for candidates using AI (LLM). + +## Overview + +The system analyzes several data sources to draft a tailored email: +1. **Job Description**: Name and summary of the target position. +2. **Candidate CV**: Skills and experiences extracted by HRFlow. +3. **AI Synthesis**: Previously generated strengths/weaknesses analysis. +4. **Extra Documents**: Interview transcripts, technical tests, or notes attached to the candidate profile. +5. **User Guidelines**: Specific instructions provided by the recruiter (e.g., "Invite for interview", "Polite rejection"). + +## Workflow + +1. **Generation**: + * The recruiter opens the **Email** tab in the Candidate Panel. + * They enter optional **Guidelines** (type of email, tone, etc.). + * The `POST /api/candidates/{profile_key}/email/generate` endpoint is called. + * The backend retrieves all context (Job, CV, Docs, Synthesis) and sends it to the LLM. +2. **Review & Edit**: + * The generated Subject and Body appear in the UI. + * The recruiter can manually edit any part of the text. +3. **Sending**: + * The recruiter clicks **Open in Mail Client**. + * A Gmail direct compose window opens in a dedicated popup. + * This ensures the recruiter uses their official Gmail account, signature, and can do a final review before sending. + +## Technical Details + +### Backend +- **Service**: `backend/services/llm.py` contains the `EMAIL_SYSTEM` prompt and `generate_email` logic. +- **Router**: `backend/routers/candidates.py` handles the API endpoint and context gathering. + +### Frontend +- **Component**: `frontend/src/components/CandidatePanel.jsx` contains the `EmailTab` UI. +- **Compose URL**: Uses `https://mail.google.com/mail/?view=cm` for a reliable pre-filled Gmail experience. diff --git a/HEpiR-HREvolution/docs/architecture.md b/HEpiR-HREvolution/docs/architecture.md index 75668e3..c3b0662 100644 --- a/HEpiR-HREvolution/docs/architecture.md +++ b/HEpiR-HREvolution/docs/architecture.md @@ -9,12 +9,22 @@ Browser (React/Vite) | | HTTP /api/* v -FastAPI Backend (Python) +FastAPI Backend (Python) [In-Memory Cache Layer] | |—— HRFlow REST API (jobs, profiles, trackings, scoring, upskilling) |—— OpenRouter LLM (grading, synthesis, interview questions) ``` +## Performance Optimization Layer + +The system uses a sophisticated caching strategy to overcome the inherent latency of external API calls: + +- **Bulk Initialization (`/jobs/init`):** On application start, the backend fetches all jobs, then all trackings per-job (HRFlow requires a `job_key` on the trackings endpoint), and the last 100 profiles in parallel. This pre-populates `_CACHE` with `job_candidates_{job_key}` entries, solving the N+1 query problem. +- **Backend Cache (no TTL):** An in-memory dictionary `_CACHE` in `hrflow.py` stores candidate lists per job. Only `job_candidates_{job_key}` is cached. Entries are evicted surgically: grading and stage changes call `_invalidate_job_candidates(job_key)` to clear only the affected job. +- **Optimistic Score Updates:** After grading, the score pill in the candidate list updates immediately via a `candidateOverride` state in `JobView` — no re-fetch required. A background re-fetch confirms the persisted value after synthesis completes. +- **Frontend Cache (30s TTL):** A request-interceptor in `api.js` caches `GET` requests. Any `POST`/`PATCH`/`DELETE` clears the cache. +- **Persistent SWR (Stale-While-Revalidate):** The UI uses `localStorage` (managed via `storage.js`) to display the last-known-good state immediately on load, while fresh data is fetched in the background. + ## Tech Stack | Layer | Technology | @@ -58,7 +68,8 @@ HRFlow/ ├── main.jsx ← App.jsx ├── services/ - │ └── api.js ← all fetch helpers + │ ├── api.js ← all fetch helpers + in-memory cache + │ └── storage.js ← localStorage wrapper for SWR persistence ├── pages/ │ └── DashboardPage.jsx └── components/ diff --git a/HEpiR-HREvolution/docs/caching-implementation-details.md b/HEpiR-HREvolution/docs/caching-implementation-details.md new file mode 100644 index 0000000..16140fd --- /dev/null +++ b/HEpiR-HREvolution/docs/caching-implementation-details.md @@ -0,0 +1,90 @@ +# Technical Architecture: Dual-Layer Caching & Optimistic Updates + +This document describes the caching system and real-time update strategy used to optimize data fetching and UI responsiveness. + +--- + +## 1. Backend: Bulk Initialization & In-Memory Service Caching + +### Bulk Initialization (`/api/jobs/init`) +On application load, the frontend calls `/api/jobs/init`. This endpoint: +- Fetches all Jobs, all Trackings (per-job, since the HRFlow `/trackings` endpoint requires a `job_key`), and the last 100 Profiles in parallel via `asyncio.gather`. +- Reconstructs the candidate list for every job from the bulk data. +- Pre-populates `_CACHE` with `job_candidates_{job_key}` entries. + +Subsequent calls to `GET /jobs/{job_key}/candidates` return immediately from cache, avoiding N+1 profile lookups. + +### Service-Level Caching (`backend/services/hrflow.py`) +- **Storage:** Global dictionary `_CACHE` (no TTL — entries live until explicitly evicted). +- **What is cached:** `job_candidates_{job_key}` lists only. Profile and job data is always fetched live from HRFlow. + +### Cache Invalidation +Invalidation is **targeted** — only the affected job's candidate list is evicted: + +| Trigger | Function called | Keys cleared | +|---|---|---| +| Grade completes (`POST /ai/grade`) | `_invalidate_job_candidates(job_key)` | `job_candidates_{job_key}` | +| Candidate stage changes | `_invalidate_job_candidates(job_key)` (inside `update_candidate_stage`) | `job_candidates_{job_key}` | + +`patch_profile_tags` and `patch_profile_metadatas` do **not** touch the cache — they don't know which job is affected. + +--- + +## 2. Frontend: Persistent Stale-While-Revalidate (SWR) + +### Persistent Storage (`frontend/src/services/storage.js`) +Lightweight `localStorage` wrapper (prefix `hrflow_v1_`). Stores `jobs` and `candidates_{job_key}` lists across browser sessions. + +### The SWR Pattern (`DashboardPage.jsx` & `JobView.jsx`) +1. **Initial render:** Components read from `storage.get()` synchronously — UI is instant. +2. **Background fetch:** A `useEffect` triggers a network request. +3. **Graceful update:** Fresh data updates React state and is written back to `localStorage`. +4. **Job switch:** No spinner if cached candidates exist; background fetch confirms/updates. + +### Request-Level Caching (`frontend/src/services/api.js`) +- All `GET` calls are cached in a `Map` with a **30-second TTL**. +- Any `POST`, `PATCH`, or `DELETE` call calls `cache.clear()` to prevent stale reads. + +--- + +## 3. Optimistic Score Updates + +After grading, the candidate's score pill in the job list updates **immediately** — before any network re-fetch — using an optimistic update pattern. + +### Flow +1. `POST /ai/grade` returns `{ base_score, ai_adjustment }`. +2. `CandidatePanel` calls `onScoreReady({ base_score, ai_adjustment })`. +3. `DashboardPage` sets `candidateOverride` with the score data. +4. `JobView` applies the override synchronously to its `candidates` state — the score pill updates instantly. +5. In parallel, `onProcessingChange(null)` (called after synthesis) increments `candidateRefreshKey`, triggering a full `fetchCandidates` re-fetch in the background to confirm the persisted values. + +This means the score is visible the moment grading returns, independent of synthesis duration or HRFlow propagation delay. + +--- + +## 4. End-to-End Flow Example + +### User opens the dashboard +1. `DashboardPage` reads Jobs from `localStorage` — UI renders immediately. +2. App calls `/api/jobs/init`. +3. Backend fetches all jobs, trackings (per-job), and profiles in parallel. +4. Backend populates `_CACHE` for every `job_candidates_{job_key}`. +5. Fresh data arrives — UI updates without a page refresh. +6. User clicks a job — `JobView` loads candidates from `localStorage` (instant), then re-fetches from backend (cache hit, <10ms). + +### User grades a candidate +1. `POST /ai/grade` → backend writes updated score tag to HRFlow, calls `_invalidate_job_candidates(job_key)`. +2. Grade response arrives → score pill in candidate list updates immediately (optimistic update). +3. Synthesis runs in background. +4. After synthesis, `fetchCandidates` re-fetches from backend — cache was invalidated, so fresh profile data is returned from HRFlow. + +--- + +## 5. Performance Summary + +| Metric | No cache | With caching | +|---|---|---| +| Initial app load | ~5s | **~50ms** (localStorage) | +| Job switch | ~2–4s | **Instant** (<10ms, localStorage) | +| Score pill after grading | After full re-fetch (~2s+) | **Instant** (optimistic update) | +| Candidate list after grading | Stale until manual refresh | **Fresh** (cache invalidated, background re-fetch) | diff --git a/HEpiR-HREvolution/docs/email-webhook-implementation.md b/HEpiR-HREvolution/docs/email-webhook-implementation.md new file mode 100644 index 0000000..99b5fd2 --- /dev/null +++ b/HEpiR-HREvolution/docs/email-webhook-implementation.md @@ -0,0 +1,58 @@ +# Email Webhook Implementation Plan + +This document outlines the architecture and implementation details for the automated email-to-candidate pipeline. This feature allows candidates to apply by sending an email with their CV attached to a monitored address. + +## 1. Overview + +The system will expose a webhook endpoint (`POST /api/webhooks/email/incoming`) designed to receive parsed email data from an external provider (e.g., SendGrid Inbound Parse, Mailgun, or AWS SES). + +### High-Level Workflow +1. **Email Received**: An external provider receives an email, parses its content and attachments, and forwards it to our webhook. +2. **Attachment Extraction**: The backend extracts the first PDF attachment (the CV). +3. **Resume Parsing**: The CV is sent to HrFlow.ai via `parse_resume_file` to create a candidate profile. +4. **Job Matching**: The system fetches all active jobs and calculates a matching score for the new profile against each job. +5. **Best Match Assignment**: The candidate is automatically linked (`create_tracking`) to the job with the highest score. +6. **Initial Stage**: The candidate is placed in the "applied" stage. + +## 2. Technical Components + +### New Endpoint: `POST /api/webhooks/email/incoming` +* **Format**: `multipart/form-data` (Standard for most inbound email providers). +* **Payload Fields** (Typical): + * `from`: Sender's email address. + * `subject`: Email subject. + * `text` or `html`: Email body. + * `attachment-count`: Number of attachments. + * `attachment-1`, `attachment-2`, etc.: The actual file attachments. + +### Logic Flow (Backend) +1. **Extract Sender & Subject**: Log the incoming application. +2. **Find PDF Attachment**: Iterate through the attachments to find the first `.pdf` file. +3. **HrFlow Parsing**: Call `hrflow.parse_resume_file(file_bytes, filename)`. +4. **Score Against All Jobs**: + ```python + jobs = await hrflow.list_jobs() + best_job = None + highest_score = -1.0 + + for job in jobs: + score = await hrflow.get_profile_score(job["key"], profile_key) + if score and score > highest_score: + highest_score = score + best_job = job + ``` +5. **Finalize Tracking**: If `best_job` is found, call `hrflow.create_tracking(best_job["key"], profile_key)`. + +## 3. Implementation Steps + +1. **Create Router**: Add `backend/routers/webhooks.py`. +2. **Register Router**: Include the new router in `backend/main.py`. +3. **Implement Logic**: + * Add helper to extract attachments from form data. + * Implement the matching loop. + * Add error handling for cases where no PDF is found or no jobs exist. +4. **Verification**: Create a mock script to simulate an incoming webhook request with a sample CV. + +## 4. Security Considerations +* **Source Verification**: In a production environment, we should verify the request originates from our email provider (e.g., by checking a secret header or IP whitelist). +* **Rate Limiting**: Protect the endpoint from spam to avoid exhausting HrFlow API credits. diff --git a/HEpiR-HREvolution/docs/frontend-components.md b/HEpiR-HREvolution/docs/frontend-components.md index 5c28495..e1b8664 100644 --- a/HEpiR-HREvolution/docs/frontend-components.md +++ b/HEpiR-HREvolution/docs/frontend-components.md @@ -234,7 +234,7 @@ const total = Math.min(1, Math.max(0, base_score + ai_adjustment + bonus)) Drag & drop or click-to-browse PDF uploader. **On upload (non-blocking):** -1. `POST /api/candidates/upload` with `file` + `job_key` — modal shows "⏳ Uploading…" only during parse +1. `POST /api/candidates/upload` with `file` + `job_key` — modal shows a spinner and "Analyse…" only during parse 2. On success → calls `onSuccess(data)` immediately — modal closes; grading/synthesis happen in background via `JobView` The modal does **not** wait for grading or synthesis. The user returns to the candidate list immediately after the PDF is parsed. diff --git a/HEpiR-HREvolution/docs/visual-identity.md b/HEpiR-HREvolution/docs/visual-identity.md new file mode 100644 index 0000000..e6911c3 --- /dev/null +++ b/HEpiR-HREvolution/docs/visual-identity.md @@ -0,0 +1,372 @@ +# HRévolution — Visual Identity + +> Motion is the primary identity carrier. The UI disappears so candidate data speaks. + +--- + +## Design Philosophy + +Slack-dark sidebar paired with a clean light content canvas. The motion system is what makes the product feel alive — every entrance is deliberate and felt, never jarring. + +- **Data-first**: Every element either carries meaning or is removed. The interface exists to surface candidates, not to impress. +- **Motion with purpose**: All animations serve orientation or feedback. Entrances use fade+translate. Exits are fast and accelerating (ease-in-expo). +- **Semantic color only**: Color appears exclusively to encode status (score quality, verdict, stage). No decorative hues. +- **Consistent typography**: Scores and percentages use the monospace stack. All other UI — including score labels and section headers — uses the sans-serif stack. + +--- + +## Color System + +### Base Palette + +| Role | Token | Value | Usage | +|-----------------|----------------------|-------------|-----------------------------------------------| +| Page background | `--bg` | `#f8f8f8` | Main content area | +| Surface | `--surface` | `#ffffff` | Cards, panels, modals, drawers | +| Border | `--border` | `#e0e0e0` | Dividers, input borders | +| Border strong | `--border-strong` | `#c8c8c8` | Focused inputs, strong separators | +| Text primary | `--text` | `#1d1c1d` | Headings, body copy | +| Text muted | `--text-muted` | `#616061` | Labels, metadata, placeholders | +| Accent | `--accent` | `#1264a3` | CTA buttons, active tabs, links, pipeline | +| Accent hover | `--accent-hover` | `#0b4f8a` | Button hover | + +### Sidebar (Slack-dark) + +| Role | Token | Value | +|-----------------|----------------------|-------------| +| Background | `--sidebar-bg` | `#1a1d21` | +| Item hover | `--sidebar-hover` | `#27292d` | +| Active item | `--sidebar-active` | `#1164a3` | +| Text | `--sidebar-text` | `#c9cdd2` | +| Muted text | `--sidebar-muted` | `#696f7a` | +| Border | `--sidebar-border` | `#2d3035` | + +### Semantic Score Colors + +| Level | Token | Value | Use | +|--------|------------------|-------------|------------------------------| +| High | `--score-high` | `#2bac76` | Score ≥ 70% | +| Mid | `--score-mid` | `#e8a838` | Score 45–69% | +| Low | `--score-low` | `#e01e5a` | Score < 45% | + +### Rule: No gradients. No decorative color. Shadows only for modal elevation. + +--- + +## Typography + +### Font Stack + +```css +--font-sans: 'Inter', system-ui, sans-serif; +--font-mono: 'JetBrains Mono', 'IBM Plex Mono', monospace; +``` + +`--font-mono` is reserved for: score values and percentages inside badges only. +All labels, section headers, and metadata use `--font-sans`. + +### Fluid Type Scale + +```css +--text-xs: clamp(0.625rem, 0.5vw + 0.5rem, 0.75rem); /* 10–12px */ +--text-sm: clamp(0.75rem, 0.6vw + 0.6rem, 0.875rem); /* 12–14px */ +--text-base: clamp(0.875rem, 0.8vw + 0.7rem, 1rem); /* 14–16px */ +--text-md: clamp(1rem, 1vw + 0.75rem, 1.25rem); /* 16–20px */ +``` + +Base font-size: `15px`. Line-height: `1.55`. + +### Weights & Tracking + +| Element | Weight | Tracking | Transform | +|-----------------------|--------|------------|------------| +| Page / panel titles | 700 | `−0.02em` | — | +| Card headers | 600 | `−0.01em` | — | +| Body copy | 400 | `0` | — | +| Section labels | 600 | `+0.06em` | uppercase | +| Score badge values | 600 | `+0.02em` | — (mono) | + +--- + +## Spacing System + +8-point grid. Every spacing value is a multiple of 8px (4px for micro-gaps). + +```css +--space-1: 4px; --space-2: 8px; --space-3: 12px; +--space-4: 16px; --space-5: 24px; --space-6: 32px; +--space-7: 48px; --space-8: 64px; +--page-gutter: clamp(16px, 4vw, 64px); +``` + +--- + +## Shape & Elevation + +```css +--radius: 6px; /* Buttons, inputs, chips, small cards */ +--radius-lg: 10px; /* Drawers, modals, large panels */ + +--shadow: 0 1px 3px rgba(0,0,0,.12); /* Subtle surface lift */ +--shadow-md: 0 4px 12px rgba(0,0,0,.15); /* Drawers, modals */ +``` + +Pill radius (`border-radius: 99px`) is used for score badges, verdict chips, and skill chips only. + +--- + +## Motion System + +Motion is a first-class identity signal, not polish. Every element that enters the screen is animated. Every element that leaves accelerates away. + +### Easing Tokens + +```css +--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Entrances — fast start, graceful settle */ +--ease-in-expo: cubic-bezier(0.7, 0, 0.84, 0); /* Exits — accelerates out */ +--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* State transitions */ +--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Spring overshoot — pipeline nodes */ +``` + +### Duration Tokens + +```css +--duration-fast: 150ms; /* Hover, color transitions */ +--duration-base: 300ms; /* Panel state changes */ +--duration-slow: 500ms; /* Structural transitions */ +--duration-reveal: 600ms; /* Candidate row entrance */ +``` + +### Entrance Keyframes + +```css +/* Brand / hero — blur lifts as element fades up. */ +@keyframes blurReveal { + from { opacity: 0; filter: blur(8px); transform: translateY(8px); } + to { opacity: 1; filter: blur(0); transform: translateY(0); } +} + +/* General content — minimal translate, no blur cost on many elements. */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Candidate drawer — slides from the right edge. */ +@keyframes slideInRight { + from { opacity: 0; transform: translateX(28px); } + to { opacity: 1; transform: translateX(0); } +} + +/* Modal / popover — scale + fade mirrors blur-reveal at small scale. */ +@keyframes modalEnter { + from { opacity: 0; transform: translateY(10px) scale(0.99); filter: blur(3px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} + +/* Pipeline nodes — spring scale pop. */ +@keyframes nodePop { + from { opacity: 0; transform: scale(0.4); } + to { opacity: 1; transform: scale(1); } +} + +/* Inline save confirmation (stage change, etc.) — pops in, holds, fades out. */ +@keyframes confirmPop { + 0% { opacity: 0; transform: scale(0.6); } + 20% { opacity: 1; transform: scale(1); } + 70% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.9); } +} +``` + +### Exit Keyframes + +Exits use `--ease-in-expo` (accelerating) — departing elements should feel like they are actively leaving, not fading passively. + +```css +@keyframes slideOutRight { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(28px); } } +@keyframes modalExit { from { opacity:1; transform:translateY(0) scale(1); filter:blur(0); } + to { opacity:0; transform:translateY(8px) scale(0.99); filter:blur(2px); } } +@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } } +``` + +### Animation Utility Classes + +| Class | Keyframe | Duration | Easing | Notes | +|-----------------------|-----------------|----------|-------------------|--------------------------------------------| +| `.anim-brand` | `blurReveal` | 800ms | `ease-out-expo` | Sidebar logo on page load | +| `.anim-drawer` | `slideInRight` | 400ms | `ease-out-expo` | Candidate panel entrance | +| `.anim-drawer-exit` | `slideOutRight` | 280ms | `ease-in-expo` | Candidate panel exit | +| `.anim-modal` | `modalEnter` | 350ms | `ease-out-expo` | Modals and popovers | +| `.anim-modal-exit` | `modalExit` | 220ms | `ease-in-expo` | Modal exit | +| `.anim-overlay` | `overlayFade` | 250ms | `ease-out-expo` | Backdrop fade-in | +| `.anim-overlay-exit` | `overlayFadeOut`| 250ms | `ease-in-expo` | Backdrop fade-out | +| `.anim-content` | `fadeUp` | 700ms | `ease-out-expo` | Tab content wrapper on tab switch | +| `.anim-section-label` | `blurReveal` | 600ms | `ease-out-expo` | Sidebar section labels | +| `.anim-confirm` | `confirmPop` | 1400ms | `ease-out-expo` | Inline save confirmation (stage change) | + +### Stagger Classes + +These classes use CSS custom properties for per-element delay. Pass the index as an inline style variable. + +#### `.candidate-row` — Candidate table rows +```css +animation: fadeUp var(--duration-reveal) var(--ease-out-expo) backwards; +animation-delay: calc(var(--row-index, 0) * 40ms); +``` +```jsx + +``` + +#### `.anim-tab` — Candidate panel tab buttons +```css +animation: fadeUp 0.45s var(--ease-out-expo) backwards; +animation-delay: calc(var(--tab-index, 0) * 55ms + 250ms); +``` +```jsx +
+``` +Key the tabs container to `profileKey` so the animation replays for each new candidate. + +#### `.anim-item` — Content items within tabs (cards, chips, questions, document bubbles) +```css +animation: fadeUp 0.8s var(--ease-out-expo) backwards; +animation-delay: calc(var(--item-index, 0) * 120ms + 20ms); +``` +```jsx +
+``` +With 120ms between items and ~6–7 visible entries, each item has a distinct moment before the next arrives — forming a deliberate cascade over ~1.5s. Wrap all items in a `key`-driven container to replay on tab switch. + +#### `.pipeline-node` — Pipeline stage nodes +```css +animation: nodePop 0.4s var(--ease-spring) backwards; +animation-delay: calc(var(--node-index, 0) * 60ms + 150ms); +``` +```jsx +
+``` + +### React Closing Pattern + +All dismissible panels (drawer, modals, overlays) use a `closing` state to play the exit animation before unmounting: + +```jsx +const [closing, setClosing] = useState(false) + +function handleClose() { + if (closing) return + setClosing(true) + setTimeout(onClose, 280) // match exit animation duration +} + +
+``` + +### Re-animation on Candidate Switch + +Tab containers and the pipeline are keyed to `profileKey` so React unmounts and remounts them when a new candidate is opened, replaying all entrance animations: + +```jsx +
{/* tabs replay */} + +
+``` + +--- + +## Component Patterns + +### Score Badge +- Monospace font, pill radius (`99px`), solid semantic background. +- Classes: `.score-badge.high / .mid / .low / .none`. + +### Verdict Chip +- Monospace font, pill radius, colored tint background. +- Classes: `.verdict.strong_yes / .yes / .maybe / .no`. + +### Pipeline Progress (Apple-style) +- Horizontal track: 2px gray bar, accent fill animates from `0%` to `progressPct%` over 700ms. +- Nodes: 20px circles, spring-pop stagger. Done = filled + SVG checkmark. Active = filled + white inner dot + glow ring (`box-shadow: 0 0 0 4px rgba(18,100,163,0.15)`). +- Skeleton state: 6 gray placeholder circles rendered immediately on mount. Font metrics of label placeholders match real labels exactly (`fontSize: .6rem`, `lineHeight: 1.55`) to prevent layout shift when real data arrives. + +### Stage Selector +- ` handleStageChange(e.target.value)} - disabled={stageUpdating} - > - {stages.map(st => ( - - ))} - {currentStageIdx === -1 && } - +
+ + {totalScore !== null ? `${Math.round(totalScore * 100)}%` : 'Non évalué'} + +
+ +
+ + + {translateStage(currentStage, stages)} + + +
+ +
+ {stageUpdating &&
} + {stageSaved && !stageUpdating && } + {stageError && !stageUpdating && } +
- +
- {/* Pipeline progress */} - - - {/* Processing status banner */} - {!loadingProfile && (loadingSynth || processingStatus) && ( -
-
- {loadingSynth ? 'Generating synthesis…' : processingStatus} -
- )} + {/* Pipeline progress — always rendered to reserve space, keyed to candidate */} + - {/* Tabs */} -
- {['overview', 'synthesis', 'scoring', 'documents', 'resume', 'ask'].map((tab) => ( -
setActiveTab(tab)}> - {tab.charAt(0).toUpperCase() + tab.slice(1)} + {/* Processing and Tabs area — reserved space for banner to avoid flicker */} +
+ {((loadingSynth && !synthesis) || processingStatus) && ( +
+
+ {((loadingSynth && !synthesis) || processingStatus === 'Generating synthesis…') ? 'Génération de la synthèse…' : (processingStatus === 'Updating profile…' ? 'Mise à jour du profil…' : (processingStatus || 'Chargement…'))}
- ))} + )} + + {/* Tabs — keyed to candidateRef so animation replays per profile */} +
+ {['overview', 'synthesis', 'scoring', 'documents', 'resume', 'email', 'ask'].map((tab, i) => ( +
setActiveTab(tab)} + > + {tab === 'overview' ? 'Aperçu' : + tab === 'synthesis' ? 'Synthèse' : + tab === 'scoring' ? 'Évaluation' : + tab === 'documents' ? 'Documents' : + tab === 'resume' ? 'CV' : + tab === 'email' ? 'E-mail' : + tab === 'ask' ? 'Questions' : tab}
+ ))} +
{/* Body */} -
+
{loadingProfile ? (
+ ) : activeTab === 'documents' ? ( + { + setLocalScores({ base_score: result.base_score ?? null, ai_adjustment: result.ai_adjustment ?? 0 }) + onScoreReady?.({ base_score: result.base_score ?? null, ai_adjustment: result.ai_adjustment ?? 0 }) + setDocsRefreshKey(k => k + 1) + onProcessingChange?.(candidateRef.profile_key, 'Génération de la synthèse…') + setLoadingSynth(true) + try { + const synth = await synthesizeCandidate(job.key, candidateRef.profile_key) + if (synth) { + setSynthesis(synth) + onSynthesisReady?.(synth) + } + } catch (e) { + console.error('synthesis failed:', e) + } finally { + setLoadingSynth(false) + onProcessingChange?.(candidateRef.profile_key, 'Mise à jour du profil…') + } + }} + onProcessingChange={onProcessingChange} + /> + ) : activeTab === 'resume' ? ( + + ) : activeTab === 'email' ? ( + ) : ( - <> +
{activeTab === 'overview' && ( )} {activeTab === 'synthesis' && ( - + )} {activeTab === 'scoring' && ( - )} - {activeTab === 'documents' && ( - { - setLocalScores({ base_score: result.base_score ?? null, ai_adjustment: result.ai_adjustment ?? 0 }) - onProcessingChange?.(candidateRef.profile_key, 'Generating synthesis…') - setLoadingSynth(true) - try { - const synth = await synthesizeCandidate(job.key, candidateRef.profile_key) - if (synth) setSynthesis(synth) - } catch (e) { - console.error('synthesis failed:', e) - } finally { - setLoadingSynth(false) - onProcessingChange?.(candidateRef.profile_key, null) - } - }} - onProcessingChange={onProcessingChange} + refreshKey={docsRefreshKey} /> )} - {activeTab === 'resume' && ( - - )} {activeTab === 'ask' && ( )} - +
)}
@@ -397,42 +573,141 @@ export default function CandidatePanel({ candidateRef, job, onClose, onProcessin ) } -function PipelineProgress({ stages, currentIdx }) { - // Built-in stages for progress line (exclude rejected and potentially too many custom stages) +// How many skeleton nodes to show while stages are loading +const SKELETON_NODES = 6 + +function PipelineProgress({ stages, currentIdx, onStageChange }) { const displayStages = stages.filter(s => s.key !== 'rejected').slice(0, 8) - const effectiveIdx = displayStages.findIndex(s => s.key === (stages[currentIdx]?.key)) + const effectiveIdx = displayStages.findIndex(s => s.key === stages[currentIdx]?.key) + const isLoading = displayStages.length === 0 + + // Track fill fires after nodes have had time to pop in + const [trackFill, setTrackFill] = useState(false) + useEffect(() => { + if (isLoading) return + const t = setTimeout(() => setTrackFill(true), 200) + return () => clearTimeout(t) + }, [isLoading]) + + const progressPct = displayStages.length > 1 + ? (Math.max(0, effectiveIdx) / (displayStages.length - 1)) * 100 + : 0 return ( -
- {displayStages.map((stage, i) => { - const isDone = i < effectiveIdx - const isActive = i === effectiveIdx - const isPastOrActive = i <= effectiveIdx - - return ( -
- {i < displayStages.length - 1 && ( -
- )} -
- {isDone && } -
-
- {stage.label} -
-
- ) - })} +
+
+ + {/* Track background — always visible, gives instant structure */} +
+ + {/* Track fill — draws after nodes pop in */} + {!isLoading && ( +
+ )} + +
+ {isLoading + /* ── Skeleton: gray placeholder circles + label bars ── */ + ? Array.from({ length: SKELETON_NODES }).map((_, i) => ( +
+ {/* Circle — same dimensions as real node */} +
+ {/* Label placeholder — same font metrics as real label so height is identical */} +
 
+
+ )) + /* ── Loaded: real nodes animate in with spring stagger ── */ + : displayStages.map((stage, i) => { + const isDone = i < effectiveIdx + const isActive = i === effectiveIdx + const isPast = isDone || isActive + + return ( +
!isActive && onStageChange(stage.key)} + > +
+ {isDone && ( + + + + )} + {isActive && ( +
+ )} +
+ +
+ {translateStage(stage.key, stages)} +
+
+ ) + }) + } +
+
) } @@ -444,10 +719,12 @@ function OverviewTab({ profile }) { return ( <> -
-
Skills
+
+
+ Compétence{skills.length > 1 ? 's' : ''} {skills.length > 0 && `(${skills.length})`} +
{skills.length === 0 ? ( -
No skills found
+
Aucune compétence trouvée
) : (
{skills.map((sk, i) => ( @@ -461,9 +738,11 @@ function OverviewTab({ profile }) { {experiences.length > 0 && (
-
Experience
+
+ Expérience{experiences.length > 1 ? 's' : ''} +
{experiences.map((exp, i) => ( -
+
{exp.title}
{exp.company?.name} {exp.date_start ? `· ${exp.date_start?.slice(0,4)}` : ''}
{exp.description &&
{exp.description?.slice(0, 200)}{exp.description?.length > 200 ? '…' : ''}
} @@ -474,9 +753,11 @@ function OverviewTab({ profile }) { {educations.length > 0 && (
-
Education
+
+ Formation{educations.length > 1 ? 's' : ''} +
{educations.map((edu, i) => ( -
+
{edu.title}
{edu.school?.name} {edu.date_start ? `· ${edu.date_start?.slice(0,4)}` : ''}
@@ -491,27 +772,29 @@ function SynthesisTab({ synthesis, loading }) { if (loading) return
if (!synthesis) return (
- AI synthesis will appear here once generated. + La synthèse IA apparaîtra ici une fois générée.
) return ( <> {synthesis.summary && ( -
-
Summary
+
+
Résumé
{synthesis.summary}
)} -
- - +
+ +
{synthesis.upskilling?.length > 0 && ( - +
+ +
)} ) @@ -524,7 +807,7 @@ function ChipSection({ title, items = [], color }) {
{title}
{items.map((item, i) => ( - + {typeof item === 'object' ? (item.name || item.description || JSON.stringify(item)) : item} ))} @@ -533,7 +816,276 @@ function ChipSection({ title, items = [], color }) { ) } -function ScoringTab({ hrflowScore, aiAdjustment, bonus, savedBonus, setBonus, onSaveBonus, bonusSaving }) { +function formatShortDate(iso) { + if (!iso) return '' + const d = new Date(iso) + return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) +} + +// --------------------------------------------------------------------------- +// Score Evolution Graph +// --------------------------------------------------------------------------- + +function ScoreEvolutionGraph({ profileKey, jobKey, baseScore, savedBonus, refreshKey }) { + const [docs, setDocs] = useState([]) + const [loading, setLoading] = useState(true) + const [animated, setAnimated] = useState(false) + + useEffect(() => { + if (!profileKey || !jobKey) return + setLoading(true) + setAnimated(false) + getExtraDocuments(profileKey, jobKey) + .then(data => setDocs(data.documents || [])) + .catch(() => setDocs([])) + .finally(() => setLoading(false)) + }, [profileKey, jobKey, refreshKey]) + + useEffect(() => { + if (loading) return + const t = setTimeout(() => setAnimated(true), 200) + return () => clearTimeout(t) + }, [loading]) + + // Build chronological score timeline from documents + const scoredDocs = [...docs] + .filter(d => d.delta !== null && d.delta !== undefined) + .sort((a, b) => new Date(a.uploaded_at) - new Date(b.uploaded_at)) + + const points = [] + if (baseScore !== null && baseScore !== undefined) { + points.push({ + score: Math.min(1, Math.max(0, baseScore + savedBonus / 100)), + label: 'Base', + date: null, + delta: null, + fullLabel: 'Score de base', + }) + let runningAdj = 0 + for (const doc of scoredDocs) { + runningAdj += doc.delta + const clampedAdj = Math.min(0.3, Math.max(-0.3, runningAdj)) + points.push({ + score: Math.min(1, Math.max(0, baseScore + clampedAdj + savedBonus / 100)), + label: (doc.filename || 'Doc').replace(/\.[^.]+$/, '').slice(0, 14), + date: doc.uploaded_at, + delta: doc.delta, + fullLabel: doc.filename, + }) + } + } + + // SVG layout constants + const H = 300 + const padL = 50, padR = 50, padT = 40, padB = 40 + const sidePadding = 60 // Space from the edge of the SVG to the first/last nodes + const scrollThreshold = 7 + const stepW = 120 // Pixels between nodes when scrolling + + // Calculate plot area width + const plotW = points.length > scrollThreshold + ? (points.length - 1) * stepW + : 440 // Fixed width for non-scrolling to keep it centered and tidy + + const W = plotW + padL + padR + (sidePadding * 2) + const chartH = H - padT - padB + + // getX calculates the center-aligned X coordinate for each node + const getX = (i) => { + if (points.length <= 1) return W / 2 + return padL + sidePadding + i * (plotW / (points.length - 1)) + } + const getY = (score) => padT + chartH * (1 - score) + + const pathD = points.length > 1 + ? points.map((p, i) => `${i === 0 ? 'M' : 'L'}${getX(i).toFixed(1)},${getY(p.score).toFixed(1)}`).join(' ') + : '' + + if (loading) { + return ( +
+
+
+ ) + } + + if (points.length === 0) { + return ( +
+ Évaluez le candidat pour voir l'évolution du score. +
+ ) + } + + return ( +
+
scrollThreshold ? 'auto' : 'hidden', + overflowY: 'hidden', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-lg)', + background: 'var(--surface)', + position: 'relative', + display: 'flex', + alignItems: 'flex-start' // Align to top to match sticky container + }}> + {/* Sticky Y-axis labels overlay — height must match SVG exactly */} +
+ {[1, 0.5, 0].map(v => ( +
+ {v * 100}% +
+ ))} +
+ + {/* Scrollable Graph Area */} +
scrollThreshold ? 'flex-start' : 'center', + minWidth: 0 + }}> + + {/* Y-axis reference lines at 0 / 50% / 100% across the whole width */} + {[0, 0.5, 1].map(v => ( + + ))} + + {/* Base Score Anchor Reference Line (Always visible value anchor) */} + {points.length > 0 && ( + + + + )} + + {/* Animated connecting path */} + {pathD && ( + + )} + + {/* Nodes */} + {points.map((p, i) => { + const cx = getX(i) + const cy = getY(p.score) + const pct = Math.round(p.score * 100) + const isBase = p.delta === null + const nodeColor = isBase + ? 'var(--accent)' + : p.delta > 0 ? 'var(--score-high)' : p.delta < 0 ? 'var(--score-low)' : '#9ca3af' + const deltaPct = p.delta !== null ? Math.round(p.delta * 100) : null + + return ( + + {isBase ? `Score de base : ${pct}%` : `${p.fullLabel} (${formatShortDate(p.date)}) : ${pct}% (${deltaPct >= 0 ? '+' : ''}${deltaPct}%)`} + + {/* Score label above node */} + {pct}% + + {/* Node circle */} + + + {/* White inner dot */} + + + {/* Delta badge */} + {!isBase && deltaPct !== null && deltaPct !== 0 && ( + + 0 ? '#e6f4ea' : '#fce8e8'} + /> + 0 ? 'var(--score-high)' : 'var(--score-low)'} + >{deltaPct > 0 ? `+${deltaPct}%` : `${deltaPct}%`} + + )} + + ) + })} + +
+
+
+ ) +} + +function ScoringTab({ hrflowScore, aiAdjustment, bonus, savedBonus, setBonus, onSaveBonus, bonusSaving, profileKey, jobKey, refreshKey }) { const totalScore = hrflowScore !== null && hrflowScore !== undefined ? Math.min(1, Math.max(0, hrflowScore + (aiAdjustment || 0) + savedBonus / 100)) : null @@ -545,29 +1097,29 @@ function ScoringTab({ hrflowScore, aiAdjustment, bonus, savedBonus, setBonus, on } return ( -
+
-
Score breakdown
+
Détail du score
{[ - { label: 'HRFlow Score', value: fmt(hrflowScore) }, - { label: 'AI Adjustment', value: fmtAdj(aiAdjustment) }, - { label: 'HR Bonus', value: savedBonus > 0 ? `+${savedBonus}%` : `${savedBonus}%` }, + { label: 'Score Initial', value: fmt(hrflowScore) }, + { label: 'Ajustement IA', value: fmtAdj(aiAdjustment) }, + { label: 'Bonus RH', value: savedBonus > 0 ? `+${savedBonus}%` : `${savedBonus}%` }, { label: 'Total', value: fmt(totalScore), highlight: true }, - ].map((item) => ( -
-
{item.label}
+ ].map((item, i) => ( +
+
{item.label}
{item.value}
))}
-
+
-
HR Bonus adjustment
-
Manually override the candidate score. Value between −100 and +100.
+
Ajustement du bonus RH
+
Modifier manuellement le score du candidat. Valeur entre −100 et +100.
@@ -588,21 +1140,33 @@ function ScoringTab({ hrflowScore, aiAdjustment, bonus, savedBonus, setBonus, on >+
+ +
+
Évolution du score
+ +
) } + function ResumeTab({ profile }) { const pdfUrl = profile?.attachments?.[0]?.public_url if (!pdfUrl) { return (
- No PDF attachment available for this profile. + Aucune pièce jointe PDF disponible pour ce profil.
) } @@ -618,7 +1182,140 @@ function ResumeTab({ profile }) { borderRadius: 'var(--radius)', display: 'block', }} - title="Resume PDF" + title="CV PDF" /> ) } + +function EmailTab({ job, candidateRef }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [emailData, setEmailData] = useState({ subject: '', body: '', to: candidateRef.email || '' }) + const [guidelines, setGuidelines] = useState('') + + const handleGenerate = async () => { + setLoading(true) + setError(null) + try { + const data = await generateEmail(job.key, candidateRef.profile_key, guidelines) + setEmailData({ ...emailData, subject: data.subject, body: data.body }) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + const handleOpenMailClient = () => { + if (!emailData.to || !emailData.subject || !emailData.body) return + + const subject = encodeURIComponent(emailData.subject) + const body = encodeURIComponent(emailData.body) + + // Direct Gmail Compose URL - this is much more reliable for a "popup" feel + const gmailUrl = `https://mail.google.com/mail/?view=cm&fs=1&to=${emailData.to}&su=${subject}&body=${body}` + + // Open in a real small popup window + const width = 800 + const height = 700 + const left = (window.innerWidth / 2) - (width / 2) + const top = (window.innerHeight / 2) - (height / 2) + + window.open( + gmailUrl, + 'GmailCompose', + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=yes` + ) + } + + return ( +
+
+
Envoyer un e-mail au candidat
+ +
+
+
Consignes de génération
+