"""TalentFit — Résumé ↔ Job Match & Salary Intelligence (Gradio app). Loads the trained artifacts and serves the end-to-end pipeline: NLP extraction → ML salary prediction → résumé↔JD fit match → explanation. This file is INFERENCE-ONLY. Train first with ``python src/train.py``. The OpenAI key is optional: with ``OPENAI_API_KEY`` set (locally or as a Hugging Face Space secret) the app uses LLM extraction + explanations + image OCR; without it, it falls back to a fully functional rule-based pipeline (text input only). """ from __future__ import annotations import json import os from pathlib import Path import gradio as gr from src import nlp, vision from src.inference import get_pipeline ROOT = Path(__file__).resolve().parent EXAMPLES_PATH = ROOT / "artifacts" / "examples.json" ASSETS = ROOT / "assets" SAMPLE_RESUME_IMG = ASSETS / "sample_resume.png" # Load the pipeline once at import; surface load errors in the UI (don't crash). _LOAD_ERROR: str | None = None try: PIPE = get_pipeline() except Exception as exc: # noqa: BLE001 PIPE, _LOAD_ERROR = None, str(exc) # Optional CV bonus: only shown when torch + the trained detector are present # (so the lean Hugging Face Space, which ships no torch, hides the image path). CV_AVAILABLE = vision.cv_available() def _load_examples() -> list[list[str]]: if not EXAMPLES_PATH.exists(): return [] data = json.loads(EXAMPLES_PATH.read_text(encoding="utf-8")) return [[e["resume_text"], e["job_description_text"]] for e in data] def _mode_banner() -> str: """Tell the user EXACTLY which capabilities are active right now (Punkt 1).""" if _LOAD_ERROR: return f"⚠️ **Model artifacts not loaded.** {_LOAD_ERROR}" if nlp.llm_available(): model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") ocr = " and **résumé-image OCR**" if CV_AVAILABLE else "" return (f"🤖 **LLM mode** (OpenAI `{model}`) — high-recall skill extraction, a written " f"rationale{ocr}. Upload an image and leave the text box empty to auto-transcribe it.") base = ("📐 **Rule-based mode** — runs fully offline and is complete for **text** input. " "Set `OPENAI_API_KEY` for higher-recall extraction, a richer rationale" f"{', and image OCR' if CV_AVAILABLE else ''}.") if CV_AVAILABLE: base += ("\n\n> ⚠️ **Without a key, an uploaded image is only *validated* (CV block) — " "it cannot be read. Paste the résumé text instead.**") return base def _cv_check(resume_text: str, resume_image): """CV bonus: validate an uploaded scan and (if possible) OCR it into text. Returns ``(resume_text, doc_check_markdown)``. No-op when no image is given or the CV stack is unavailable. The markdown is the *body* of the Document-check box (the section header lives in the layout). """ if resume_image is None or not CV_AVAILABLE: return resume_text, "" verdict = vision.classify_document(resume_image) p = verdict["p_resume"] lines = [f"Detected **{verdict['label']}** · p(résumé) = **{p:.0%}** " f"_(backbone `{verdict['backbone']}`)_."] if verdict["label"] != "resume": lines.append("⚠️ This page does not look like a résumé — treat the results with caution.") if not (resume_text or "").strip(): text = vision.ocr_image(resume_image) if text: resume_text = text lines.append(f"✅ Extracted **{len(text):,} characters** via OpenAI Vision OCR — " "using that as the résumé text below.") else: lines.append("🔑 _No OpenAI key set, so the image can't be transcribed. " "Paste the résumé text, or set `OPENAI_API_KEY` to enable OCR._") return resume_text, "\n\n".join(lines) def analyze(resume_text: str, jd_text: str, resume_image=None): """Run the full pipeline and format outputs for the UI.""" if _LOAD_ERROR: raise gr.Error(f"Model artifacts not loaded: {_LOAD_ERROR}") resume_text, cv_note = _cv_check(resume_text, resume_image) # Context-aware validation (Punkt 1): be explicit about WHY input is missing. if not (jd_text or "").strip(): raise gr.Error("Please paste a job description on the right.") if not (resume_text or "").strip(): if resume_image is not None and CV_AVAILABLE: if not nlp.llm_available(): raise gr.Error( "🔑 No OpenAI API key is set, so the uploaded image can't be read " "automatically (OCR is LLM-based). Please paste the résumé text, or set " "OPENAI_API_KEY to enable image OCR." ) raise gr.Error( "🖼️ The image was detected, but OCR returned no text — the OpenAI Vision call " "failed (check the Space 'Logs' tab; most likely OPENAI_API_KEY is invalid/truncated " "or the account has no quota). Please paste the résumé text as a fallback." ) raise gr.Error("Please provide a résumé — paste text, or upload an image (with an OpenAI key for OCR).") res = PIPE.analyze(resume_text, jd_text, prefer_llm=True) # -- Fit (NLP) ---------------------------------------------------------- # scores = res["match"]["scores"] # {class: prob} → gr.Label # -- Salary (ML) -------------------------------------------------------- # s = res["salary"] salary_md = ( f"## ${s['point']:,.0f} / year\n" f"Likely range **${s['low']:,.0f} – ${s['high']:,.0f}** \n" f"_±${(s['high'] - s['point']):,.0f} (model MAE)_" ) # -- Skill match (NLP) → coverage + colour-coded chips ------------------ # matching = res["matching_skills"] missing = res["missing_skills"] total = len(matching) + len(missing) cov = res["skill_coverage"] cov_txt = f"{cov:.0%}" if cov is not None else "n/a" coverage_md = ( f"**Coverage {cov_txt}** — the candidate has **{len(matching)} of {total}** " f"skills required by the job." if total else "_No explicit technical skills were detected in the job description._" ) highlighted = ( [(f" {sk} ", "candidate has") for sk in matching] + [(f" {sk} ", "missing (gap)") for sk in missing] ) or [("—", None)] # -- Rationale (NLP) ---------------------------------------------------- # badge = "🤖 LLM extraction" if res["used_llm"] else "📐 rule-based extraction" rationale_md = f"_({badge})_\n\n{res['explanation']}" ex = res["extraction"] outputs = [scores, salary_md, coverage_md, highlighted] if CV_AVAILABLE: outputs.append(cv_note or "_No image uploaded — this section fills in when you upload a résumé scan._") outputs += [rationale_md, ex] return tuple(outputs) with gr.Blocks(title="TalentFit") as demo: gr.Markdown( "# 🎯 TalentFit — Résumé ↔ Job Match & Salary Intelligence\n" "Paste a **résumé** and a **job description**. TalentFit returns a **fit score**, " "an **expected salary range**, and a **grounded rationale with skill gaps**.\n\n" "> ⚖️ *Decision support for a human reviewer — not an automated hiring decision.*" ) gr.Markdown(_mode_banner()) # -- Inputs: left = résumé text + image (≈50/50); right = JD as tall as both (Punkt 2) with gr.Row(): with gr.Column(scale=1): resume_in = gr.Textbox(label="📄 Résumé text", lines=14, placeholder="Paste the candidate's résumé here…") resume_img_in = ( gr.Image(label="🖼️ …or upload a résumé scan (validated by CV; OCR'd when an OpenAI key is set)", type="pil", height=300) if CV_AVAILABLE else None ) with gr.Column(scale=1): jd_in = gr.Textbox(label="📋 Job description", lines=30 if CV_AVAILABLE else 14, placeholder="Paste the job description here…") analyze_btn = gr.Button("Analyze fit & salary", variant="primary") # -- Outputs (flat layout, thin dividers for separation — no grey panels) - # gr.Markdown("## 📊 Results") with gr.Row(equal_height=True): with gr.Column(scale=1): gr.Markdown("### 🤝 Fit prediction  ·  *NLP block*") fit_out = gr.Label(num_top_classes=3, show_label=False) gr.Markdown( "TF-IDF + Logistic Regression on labelled résumé/JD pairs — how well " "**this résumé matches this specific job** (No / Potential / Good Fit)." ) with gr.Column(scale=1): gr.Markdown("### 💰 Expected salary  ·  *ML block*") salary_out = gr.Markdown() gr.Markdown( "RandomForest regression on public job-postings — the **market salary " "for the role** (derived from the job description), not the candidate's pay." ) gr.Markdown("---") gr.Markdown("### 🧩 Skill match  ·  *NLP block*") coverage_out = gr.Markdown() skills_out = gr.HighlightedText( label="Skills required by the job", color_map={"candidate has": "green", "missing (gap)": "red"}, show_legend=True, combine_adjacent=False, ) if CV_AVAILABLE: gr.Markdown("---") gr.Markdown("### 🖼️ Document check  ·  *Computer-Vision block*") doc_check_out = gr.Markdown() gr.Markdown("---") gr.Markdown("### 📝 Rationale  ·  *NLP block*") rationale_out = gr.Markdown() with gr.Accordion("🔍 Extracted structured fields (the NLP → ML bridge)", open=False): extraction_out = gr.JSON() # Wire up (inputs + outputs are conditional on the CV image path). _inputs = [resume_in, jd_in] + ([resume_img_in] if CV_AVAILABLE else []) _outputs = [fit_out, salary_out, coverage_out, skills_out] if CV_AVAILABLE: _outputs.append(doc_check_out) _outputs += [rationale_out, extraction_out] analyze_btn.click(analyze, inputs=_inputs, outputs=_outputs) examples = _load_examples() if examples: gr.Examples(examples=examples, inputs=[resume_in, jd_in], label="📋 Example résumé / job-description pairs (text)") # Sample résumé *scan* to try the CV + OCR path (only when the image path is shown). if CV_AVAILABLE and SAMPLE_RESUME_IMG.exists() and examples: gr.Examples( examples=[["", str(SAMPLE_RESUME_IMG), examples[0][1]]], inputs=[resume_in, resume_img_in, jd_in], label="🖼️ Sample résumé scan — try the Computer-Vision + OCR path (OCR needs an OpenAI key)", cache_examples=False, ) gr.Markdown( "---\n" "*🤝 **Fit** = NLP (TF-IDF + Logistic Regression) · " "💰 **Salary** = ML (RandomForest regression) · " "🖼️ **Document check** = Computer Vision (MobileNetV3 + Logistic Regression). " "See the project documentation for metrics, comparisons and limitations.*" ) if __name__ == "__main__": theme = gr.themes.Soft() # On Hugging Face Spaces, disable SSR to avoid a known blank-page issue. if os.environ.get("SPACE_ID"): demo.queue().launch(ssr_mode=False, theme=theme) else: demo.launch(theme=theme)