"""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)