job-processor / services /job_matcher.py
Eng-Musa's picture
own processor
2d765ca
Raw
History Blame Contribute Delete
37.1 kB
from __future__ import annotations
import hashlib
import json
import threading
from typing import Any, Dict, List, Optional, Set, Tuple
import numpy as np
import torch
from cachetools import TTLCache
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
from services.processor_utils import (
DEFAULT_SKILL_ALIASES,
DEFAULT_CATEGORY_SKILLS,
SENIORITY_ORDER,
)
# ---------------------------------------------------------------------------
# Requires (for the ONNX path): pip install "optimum[onnxruntime]" onnxruntime
# If these aren't installed, model loading below automatically falls back to
# the plain PyTorch backend -- nothing crashes, it just runs at pre-ONNX speed.
# ---------------------------------------------------------------------------
try:
import onnxruntime as ort
_ONNXRUNTIME_AVAILABLE = True
except ImportError:
_ONNXRUNTIME_AVAILABLE = False
# ---------------------------------------------------------------------------
# Pin CPU thread count to the box's vCPU count. Left unpinned, PyTorch's
# (and separately, ONNX Runtime's) default intra-op threading can
# oversubscribe a 2-vCPU host and cause contention between concurrent
# encode() calls.
# ---------------------------------------------------------------------------
torch.set_num_threads(2)
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class JobInput(BaseModel):
"""Strictly typed job posting for JobMatcher.predict()."""
job_id: str
job_title: str
company_name: str
description: str
skills_canonical: List[str] = Field(default_factory=list)
skills_technical: List[str] = Field(default_factory=list)
skills_soft: List[str] = Field(default_factory=list)
category: str = "Other"
seniority: str = "mid"
min_years_experience: int = 0
class ScoreBreakdown(BaseModel):
skill_score: float
semantic_score: float
title_score: float
category_score: float
seniority_score: float
experience_score: float
category_modifier: float = 0.0
class MismatchAnalysis(BaseModel):
reason: str
matched_technical_skills: List[str]
missing_technical_skills: List[str]
matched_soft_skills: List[str]
missing_soft_skills: List[str]
cv_extra_strengths: List[str]
seniority_fit: str
category_fit: str
improvement_advice: List[str]
class MatchPrediction(BaseModel):
job_id: str
job_title: str
company_name: str
match_score: str
match_level: str
cv_title: str
cv_seniority: str
cv_category: str
cv_skills: List[str]
cv_years_experience: int
score_breakdown: ScoreBreakdown
mismatch_analysis: MismatchAnalysis
# ---------------------------------------------------------------------------
# Pure scoring helpers
# ---------------------------------------------------------------------------
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a, b) / (norm_a * norm_b))
def _seniority_fit(cv_seniority: str, job_seniority: str) -> Tuple[float, str]:
cv_rank = SENIORITY_ORDER.get(cv_seniority, 1)
job_rank = SENIORITY_ORDER.get(job_seniority, 2)
diff = cv_rank - job_rank
if diff == 0:
return 1.0, f"Good seniority fit ({cv_seniority}{job_seniority})."
if diff == -1:
return (
0.65,
f"Slightly under the requested level ({cv_seniority}{job_seniority}).",
)
if diff < -1:
return 0.25, f"Seniority mismatch ({cv_seniority} CV for {job_seniority} role)."
return (
0.85,
f"Candidate may be more experienced than required ({cv_seniority}{job_seniority}).",
)
def _experience_fit(cv_years: int, required_years: int) -> float:
if required_years <= 0:
return 1.0
if cv_years <= 0:
return 0.55 if required_years <= 1 else 0.35
return max(0.1, min(1.0, cv_years / required_years))
def _soft_category_score(
cv_skills: Set[str],
job_category: str,
category_skills: Dict[str, Set[str]],
) -> float:
"""
Jaccard-based soft category match instead of binary 1.0 / 0.30.
Scores range from 0.30 (no overlap) to 1.0 (exact match).
"""
job_cat_skills = category_skills.get(job_category, set())
if not job_cat_skills:
return 0.30
intersection = len(cv_skills & job_cat_skills)
union = len(cv_skills | job_cat_skills)
if union == 0:
return 0.30
jaccard = intersection / union
return max(0.30, min(1.0, 0.30 + jaccard * 0.70))
def _match_level(score: float) -> str:
if score >= 80:
return "Excellent Match"
if score >= 70:
return "Strong Match"
if score >= 55:
return "Good Match"
if score >= 40:
return "Moderate Match"
if score >= 25:
return "Weak Match"
return "Very Weak Match"
def _build_advice(
missing_technical: List[str],
job: JobInput,
cv_category: str,
) -> List[str]:
advice: List[str] = []
if missing_technical:
advice.append(
"Add evidence for these technical skills if you have them: "
+ ", ".join(missing_technical[:5])
+ "."
)
if cv_category != job.category:
advice.append(
f"This CV looks closer to {cv_category}; tailor the summary and "
f"projects toward {job.category} for this job."
)
if job.seniority == "senior":
advice.append(
"For senior roles, highlight leadership responsibilities, team size "
"managed, and measurable achievements."
)
if not advice:
advice.append(
"The CV is well aligned; strengthen it with measurable achievements "
"and recent work examples."
)
return advice[:4]
# ---------------------------------------------------------------------------
# Main class
# ---------------------------------------------------------------------------
class JobMatcher:
"""
CV-to-job matcher using sentence embeddings + skill taxonomy scoring.
Single-job usage
----------------
matcher = JobMatcher()
result = matcher.predict(job_dict, cv_markdown)
Batch usage (same CV, many jobs — CV processed only once)
---------------------------------------------------------
results = matcher.predict_batch(list_of_job_dicts, cv_markdown)
Performance notes (batched implementation)
-------------------------------------------
All model.encode() calls are batched ACROSS jobs, not per-job:
- 1 call embeds every job's semantic text
- 1 call embeds every job's title
- 1 call embeds the (deduplicated) set of missing technical skills
across *all* jobs, used for semantic skill fallback matching
- The CV title vector is computed once and cached (was previously
recomputed on every job).
This means a request with N jobs makes a fixed ~4 model.encode() calls
total instead of up to 4*N + 1, regardless of N. Scoring math and
output are unchanged — this is purely a batching optimization.
Model choice
------------
Semantic model is BAAI/bge-small-en-v1.5 (33M params, standard BERT
encoder architecture, 384-dim, 512-token context). This replaces
jina-embeddings-v2-base-en (137M, custom ALiBi remote-code arch).
The jina model's custom architecture does not reliably export via
optimum's ONNX path, so on most boxes it was silently falling back to
full PyTorch fp32 despite use_onnx=True. bge-small has published
quantized ONNX weights matching the _ONNX_FILE_CANDIDATES naming
below, ships an order of magnitude fewer parameters, and stays within
a couple of points of jina-base on semantic similarity benchmarks —
it does not meaningfully change match_score behavior for this use
case since semantic similarity is only 23% of the final weighted
score to begin with.
Title model is UNCHANGED (TechWolf/JobBERT-v3, falling back to v2) —
title matching is cheap (short strings) and this model is
domain-specific for job titles, so there's no reason to swap it.
Truncation was reduced from 4000 to 1500 chars (job text) / 2000
chars (CV) — most of the matching signal lives in the first few
hundred words, and this cuts attention cost roughly in half again on
top of the smaller model, with negligible score impact in practice.
Text is no longer manually chunked into overlapping ~180-word windows
before embedding; the smaller model's 512-token window comfortably
covers the reduced truncation length in a single forward pass.
Both models are loaded via the ONNX Runtime backend (with int8
quantization where a quantized export is available on the model repo)
for faster CPU inference, with automatic fallback to plain PyTorch if
ONNX packages or ONNX weights aren't available — see _load_model().
The active backend is logged explicitly after load so a silent
fallback to PyTorch is visible instead of assumed away.
CV caching
----------
Parsing + embedding a CV (cv_vec, cv_title_vec, individual_skills_vecs)
is the fixed per-request cost that doesn't shrink with batching. It's
cached in-process for a configurable TTL, keyed by a hash of the CV
inputs, using cachetools.TTLCache (pure in-memory, no external
service required, thread-safe via an internal lock). A repeat request
for the same CV — e.g. pagination across job results — skips CV
parsing and all three CV-side encode() calls entirely.
"""
# Candidate ONNX file names to try, in preference order (most
# aggressively quantized first). `None` means "let sentence-transformers
# auto-select / auto-export the default ONNX model". Not every model
# repo ships every variant, so we try each and move on if one 404s.
_ONNX_FILE_CANDIDATES: List[Optional[str]] = [
"onnx/model_qint8_avx512_vnni.onnx",
"onnx/model_quantized.onnx",
"onnx/model_qint8.onnx",
"onnx/model_int8.onnx",
"onnx/model.onnx",
None,
]
# Truncation lengths — kept as class constants so they're easy to tune
# without hunting through the method bodies.
_JOB_TEXT_MAX_CHARS: int = 1500
_CV_TEXT_MAX_CHARS: int = 2000
def __init__(
self,
semantic_model_name: str = "BAAI/bge-small-en-v1.5",
title_model_name: str = "TechWolf/JobBERT-v2",
skill_aliases: Optional[Dict[str, List[str]]] = None,
category_skills: Optional[Dict[str, Set[str]]] = None,
use_onnx: bool = True,
cv_cache_maxsize: int = 256,
cv_cache_ttl_seconds: int = 600,
) -> None:
# bge-small does not need trust_remote_code — it's a standard
# BERT-family architecture, which is exactly why it exports to
# ONNX reliably (unlike jina's custom ALiBi remote code).
self._model = self._load_model(
semantic_model_name, use_onnx=use_onnx, trust_remote_code=False
)
try:
self._title_model = self._load_model(
"TechWolf/JobBERT-v3", use_onnx=use_onnx, trust_remote_code=False
)
except Exception:
self._title_model = self._load_model(
title_model_name, use_onnx=use_onnx, trust_remote_code=False
)
self._aliases: Dict[str, List[str]] = skill_aliases or DEFAULT_SKILL_ALIASES
self._category_skills: Dict[str, Set[str]] = (
category_skills or DEFAULT_CATEGORY_SKILLS
)
# In-process CV embedding cache. No external service, no setup —
# just a bounded, TTL'd dict living in this process's memory.
# Guarded by a lock since TTLCache itself is not thread-safe.
self._cv_cache: TTLCache = TTLCache(
maxsize=cv_cache_maxsize, ttl=cv_cache_ttl_seconds
)
self._cv_cache_lock = threading.RLock()
# ------------------------------------------------------------------
# Internal: load a SentenceTransformer via ONNX Runtime if possible,
# trying quantized variants first, falling back to plain PyTorch.
# ------------------------------------------------------------------
@classmethod
def _load_model(
cls,
model_name: str,
use_onnx: bool = True,
trust_remote_code: bool = False,
) -> SentenceTransformer:
if use_onnx and _ONNXRUNTIME_AVAILABLE:
session_options = None
try:
session_options = ort.SessionOptions()
session_options.intra_op_num_threads = 2
session_options.inter_op_num_threads = 1
except Exception:
session_options = None
for file_name in cls._ONNX_FILE_CANDIDATES:
try:
model_kwargs: Dict[str, Any] = {}
if file_name:
model_kwargs["file_name"] = file_name
if session_options is not None:
model_kwargs["session_options"] = session_options
model = SentenceTransformer(
model_name,
backend="onnx",
trust_remote_code=trust_remote_code,
model_kwargs=model_kwargs,
)
print(
f"[JobMatcher] Loaded '{model_name}' via ONNX Runtime "
f"(file={file_name or 'auto-export'})."
)
cls._log_active_backend(model, model_name)
return model
except Exception as exc:
print(
f"[JobMatcher] ONNX load attempt failed for "
f"'{model_name}' (file={file_name or 'auto-export'}): {exc}"
)
elif use_onnx and not _ONNXRUNTIME_AVAILABLE:
print(
"[JobMatcher] onnxruntime/optimum not installed — "
f"falling back to PyTorch backend for '{model_name}'. "
'Install with: pip install "optimum[onnxruntime]" onnxruntime'
)
print(f"[JobMatcher] Loading '{model_name}' via PyTorch backend.")
model = SentenceTransformer(model_name, trust_remote_code=trust_remote_code)
cls._log_active_backend(model, model_name)
return model
@staticmethod
def _log_active_backend(model: SentenceTransformer, model_name: str) -> None:
"""
Explicitly verify + log which backend actually ended up active.
The try/except ladder in _load_model can silently fall through to
PyTorch even when use_onnx=True, so this makes that visible
instead of assumed.
"""
backend = getattr(model, "backend", None) or "pytorch"
print(f"[JobMatcher] '{model_name}' ACTIVE backend = {backend}")
if backend != "onnx":
print(
f"[JobMatcher] NOTE: '{model_name}' is running on PyTorch, not ONNX. "
"Check that 'optimum[onnxruntime]' and 'onnxruntime' are installed "
"and that the model repo has quantized ONNX weights."
)
# ------------------------------------------------------------------
# Public: single-job prediction (backward-compatible)
# ------------------------------------------------------------------
def predict(
self,
job_json: Dict[str, Any],
cv_markdown: Optional[str] = None,
cv_title: Optional[str] = None,
cv_years: Optional[int] = None,
cv_seniority: Optional[str] = None,
cv_skills_canonical: Optional[List[str]] = None,
cv_skills_technical: Optional[List[str]] = None,
cv_skills_soft: Optional[List[str]] = None,
cv_category: Optional[str] = None,
) -> Dict[str, Any]:
"""
Predict the match between one job and one CV.
Implemented as a thin wrapper around predict_batch([job]) so there
is a single scoring code path to keep in sync.
"""
results = self.predict_batch(
jobs=[job_json],
cv_markdown=cv_markdown or "",
cv_title=cv_title or "Unknown",
cv_years=cv_years or 0,
cv_seniority=cv_seniority or "mid",
cv_skills_canonical=cv_skills_canonical or [],
cv_skills_technical=cv_skills_technical or [],
cv_skills_soft=cv_skills_soft or [],
cv_category=cv_category or "Other",
)
return results[0]
# ------------------------------------------------------------------
# Public: batch prediction (one CV vs. N jobs)
# ------------------------------------------------------------------
def predict_batch(
self,
jobs: List[Dict[str, Any]],
cv_markdown: str,
cv_title: str,
cv_years: int,
cv_seniority: str,
cv_skills_canonical: List[str],
cv_skills_technical: List[str],
cv_skills_soft: List[str],
cv_category: str,
) -> List[Dict[str, Any]]:
"""
Match one CV against multiple jobs.
The CV is parsed and embedded **once** (or fetched from cache if
an identical CV was matched recently). All job-level embeddings
(semantic text, title, missing-skill fallback) are computed in
batched encode() calls across every job in the request, then
scoring is done purely in numpy with no further model calls.
"""
cv_data = self._get_or_parse_cv(
cv_markdown=cv_markdown,
cv_title=cv_title,
cv_years=cv_years,
cv_seniority=cv_seniority,
cv_skills_canonical=cv_skills_canonical,
cv_skills_technical=cv_skills_technical,
cv_skills_soft=cv_skills_soft,
cv_category=cv_category,
)
job_objs = [JobInput(**j) for j in jobs]
n = len(job_objs)
if n == 0:
return []
# ---- Batch 1: job semantic-text embeddings, ONE encode() call ----
job_texts = [self._build_job_index_text(j) for j in job_objs]
job_vecs = self._embed_batch_texts(job_texts)
# ---- Batch 2: job title embeddings, ONE encode() call ----
job_title_vecs = self._embed_title_batch([j.job_title for j in job_objs])
# ---- Precompute exact skill matches + collect missing technical
# skills across ALL jobs for a single batched semantic pass ----
cv_skills_technical_set = cv_data["cv_skills_technical"]
per_job_technical: List[Set[str]] = []
per_job_soft: List[Set[str]] = []
per_job_matched_technical: List[List[str]] = []
per_job_missing_technical: List[List[str]] = []
all_missing_skills: Set[str] = set()
for job in job_objs:
job_technical = {
s.strip().lower() for s in job.skills_technical if s.strip()
}
job_soft = {s.strip().lower() for s in job.skills_soft if s.strip()}
matched = sorted(cv_skills_technical_set & job_technical)
missing = sorted(job_technical - cv_skills_technical_set)
per_job_technical.append(job_technical)
per_job_soft.append(job_soft)
per_job_matched_technical.append(matched)
per_job_missing_technical.append(missing)
all_missing_skills.update(missing)
# ---- Batch 3: semantic skill-matching embeddings, ONE encode()
# call for the deduplicated union of missing skills across
# every job in the request ----
missing_skill_vecs: Dict[str, np.ndarray] = {}
individual_skills_vecs = cv_data["individual_skills_vecs"]
if all_missing_skills and len(individual_skills_vecs) > 0:
unique_missing = sorted(all_missing_skills)
contextualized = [f"software development skill {s}" for s in unique_missing]
vecs = self._model.encode(
contextualized,
normalize_embeddings=True,
show_progress_bar=False,
batch_size=32,
)
missing_skill_vecs = dict(zip(unique_missing, np.asarray(vecs)))
# ---- Score each job — pure numpy from here, no model calls ----
results = [
self._score_job(
job=job_objs[i],
cv_data=cv_data,
job_vec=job_vecs[i],
job_title_vec=job_title_vecs[i],
job_technical=per_job_technical[i],
job_soft=per_job_soft[i],
matched_technical=per_job_matched_technical[i],
missing_technical=per_job_missing_technical[i],
missing_skill_vecs=missing_skill_vecs,
)
for i in range(n)
]
results.sort(
key=lambda x: float(x["match_score"].rstrip("%")),
reverse=True,
)
return results
# ------------------------------------------------------------------
# Internal: CV cache lookup / population
# ------------------------------------------------------------------
def _cv_cache_key(
self,
cv_markdown: str,
cv_title: str,
cv_years: int,
cv_seniority: str,
cv_skills_canonical: List[str],
cv_skills_technical: List[str],
cv_skills_soft: List[str],
cv_category: str,
) -> str:
"""
Deterministic hash of every input that affects CV parsing/embedding.
Truncate the markdown to the same length actually used for
embedding so two CVs differing only past the truncation point
still hit the same cache entry.
"""
payload = {
"cv_markdown": (cv_markdown or "")[: self._CV_TEXT_MAX_CHARS],
"cv_title": cv_title or "",
"cv_years": cv_years,
"cv_seniority": cv_seniority,
"cv_skills_canonical": sorted(cv_skills_canonical or []),
"cv_skills_technical": sorted(cv_skills_technical or []),
"cv_skills_soft": sorted(cv_skills_soft or []),
"cv_category": cv_category or "",
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _get_or_parse_cv(
self,
cv_markdown: str,
cv_title: str,
cv_years: int,
cv_seniority: str,
cv_skills_canonical: List[str],
cv_skills_technical: List[str],
cv_skills_soft: List[str],
cv_category: str,
) -> Dict[str, Any]:
cache_key = self._cv_cache_key(
cv_markdown=cv_markdown,
cv_title=cv_title,
cv_years=cv_years,
cv_seniority=cv_seniority,
cv_skills_canonical=cv_skills_canonical,
cv_skills_technical=cv_skills_technical,
cv_skills_soft=cv_skills_soft,
cv_category=cv_category,
)
with self._cv_cache_lock:
cached = self._cv_cache.get(cache_key)
if cached is not None:
return cached
cv_data = self._parse_cv(
cv_markdown=cv_markdown,
cv_title=cv_title,
cv_years=cv_years,
cv_seniority=cv_seniority,
cv_skills_canonical=cv_skills_canonical,
cv_skills_technical=cv_skills_technical,
cv_skills_soft=cv_skills_soft,
cv_category=cv_category,
)
with self._cv_cache_lock:
self._cv_cache[cache_key] = cv_data
return cv_data
# ------------------------------------------------------------------
# Internal: parse + embed CV (done once per unique CV, then cached)
# ------------------------------------------------------------------
def _parse_cv(
self,
cv_markdown: str,
cv_title: str,
cv_years: int,
cv_seniority: str,
cv_skills_canonical: List[str],
cv_skills_technical: List[str],
cv_skills_soft: List[str],
cv_category: str,
) -> Dict[str, Any]:
"""
Return a dict holding all CV-level data needed by _score_job.
"""
cv_skills_set = set(cv_skills_canonical)
cv_skills_technical_set = set(cv_skills_technical)
cv_skills_soft_set = set(cv_skills_soft)
cv_vec = (
self._embed(cv_markdown[: self._CV_TEXT_MAX_CHARS])
if cv_markdown
else np.zeros(384, dtype="float32")
)
# Step 1: compute the CV title vector once here instead of
# recomputing it inside every per-job title-similarity call.
cv_title_vec = (
self._embed_title(cv_title) if cv_title and cv_title != "Unknown" else None
)
individual_skills_list = list(cv_skills_technical_set | cv_skills_soft_set)
if individual_skills_list:
contextualized_cv = [
f"software development skill {s}" for s in individual_skills_list
]
individual_skills_vecs = self._model.encode(
contextualized_cv,
normalize_embeddings=True,
show_progress_bar=False,
batch_size=32,
)
else:
individual_skills_vecs = np.array([])
return {
"cv_title": cv_title,
"cv_title_vec": cv_title_vec,
"cv_years": cv_years,
"cv_seniority": cv_seniority,
"cv_skills": cv_skills_set,
"cv_skills_technical": cv_skills_technical_set,
"cv_skills_soft": cv_skills_soft_set,
"cv_category": cv_category,
"cv_vec": cv_vec,
"individual_skills_list": individual_skills_list,
"individual_skills_vecs": individual_skills_vecs,
}
# ------------------------------------------------------------------
# Internal: score one job against pre-parsed CV data + precomputed
# job-level vectors (no model calls happen in this method).
# ------------------------------------------------------------------
def _score_job(
self,
job: JobInput,
cv_data: Dict[str, Any],
job_vec: np.ndarray,
job_title_vec: np.ndarray,
job_technical: Set[str],
job_soft: Set[str],
matched_technical: List[str],
missing_technical: List[str],
missing_skill_vecs: Dict[str, np.ndarray],
) -> Dict[str, Any]:
cv_title = cv_data["cv_title"]
cv_title_vec = cv_data["cv_title_vec"]
cv_years = cv_data["cv_years"]
cv_seniority = cv_data["cv_seniority"]
cv_skills = cv_data["cv_skills"]
cv_skills_soft = cv_data["cv_skills_soft"]
cv_category = cv_data["cv_category"]
cv_vec = cv_data["cv_vec"]
individual_skills_vecs = cv_data["individual_skills_vecs"]
# ── Semantic similarity (job embedding precomputed in the batch) ──
semantic = max(0.0, min(1.0, _cosine_similarity(cv_vec, job_vec)))
# ── Skill scoring ───────────────────────────────────────────────
matched_technical = list(matched_technical)
missing_technical = list(missing_technical)
# Semantic fallback for missing technical skills, using the
# precomputed (batched, deduplicated) missing-skill vector lookup.
if missing_technical and len(individual_skills_vecs) > 0 and missing_skill_vecs:
semantic_matches = self._semantic_skill_matches_precomputed(
missing_technical,
missing_skill_vecs,
individual_skills_vecs,
threshold=0.88,
)
if semantic_matches:
matched_technical = sorted(set(matched_technical) | semantic_matches)
missing_technical = sorted(set(missing_technical) - semantic_matches)
matched_soft = sorted(cv_skills_soft & job_soft)
missing_soft = sorted(job_soft - cv_skills_soft)
extra = sorted(cv_skills - job_technical - job_soft)[:8]
tech_denom = min(len(job_technical), 12) if job_technical else 1
soft_denom = min(len(job_soft), 3) if job_soft else 1
tech_score = (
min(1.0, len(matched_technical) / tech_denom) if job_technical else 0.50
)
soft_score = min(1.0, len(matched_soft) / soft_denom) if job_soft else 0.50
skill = min(1.0, (tech_score * 0.80) + (soft_score * 0.20))
# ── Component scores ─────────────────────────────────────────────
cv_cat_lower = cv_category.strip().lower() if cv_category else ""
job_cat_lower = job.category.strip().lower() if job.category else ""
if cv_cat_lower and job_cat_lower and cv_cat_lower == job_cat_lower:
cat_score = 1.0
else:
cat_score = _soft_category_score(
cv_skills, job.category, self._category_skills
)
seniority, sen_reason = _seniority_fit(cv_seniority, job.seniority)
title = self._title_similarity_precomputed(
cv_title, cv_title_vec, job_title_vec
)
experience = _experience_fit(cv_years, job.min_years_experience)
# ── Weighted aggregation ─────────────────────────────────────────
base_final = (
0.42 * skill
+ 0.23 * semantic
+ 0.12 * title
+ 0.11 * cat_score
+ 0.07 * seniority
+ 0.05 * experience
) * 100
# Smooth Modifier applies universally
if cat_score < 0.75:
cat_modifier = -20.0 + ((cat_score - 0.30) / 0.45) * 20.0
cat_modifier = max(-20.0, min(0.0, cat_modifier))
else:
cat_modifier = ((cat_score - 0.75) / 0.25) * 10.0
cat_modifier = max(0.0, min(10.0, cat_modifier))
final = base_final + cat_modifier
# Guardrails
if job_technical and not matched_technical:
final = min(final, 34.0)
if cv_cat_lower != job_cat_lower and skill < 0.30:
final = min(final, 42.0)
if (
job.seniority == "senior"
and SENIORITY_ORDER.get(cv_seniority, 1) < SENIORITY_ORDER["mid"]
):
final = min(final, 45.0)
final = round(max(0.0, min(100.0, final)), 2)
level = _match_level(final)
# ── Reason string ────────────────────────────────────────────────
if matched_technical or matched_soft:
reason = (
f"{level}: matched {len(matched_technical)}/{len(job_technical)} technical skill(s) and "
f"{len(matched_soft)}/{len(job_soft)} soft skill(s)."
)
else:
reason = (
f"{level}: the CV does not show the main technical skills for this "
f"role. It looks closer to {cv_category}."
)
prediction = MatchPrediction(
job_id=job.job_id,
job_title=job.job_title,
company_name=job.company_name,
match_score=f"{final}%",
match_level=level,
cv_title=cv_title,
cv_seniority=cv_seniority,
cv_category=cv_category,
cv_skills=sorted(cv_skills),
cv_years_experience=cv_years,
score_breakdown=ScoreBreakdown(
skill_score=round(skill * 100, 2),
semantic_score=round(semantic * 100, 2),
title_score=round(title * 100, 2),
category_score=round(cat_score * 100, 2),
seniority_score=round(seniority * 100, 2),
experience_score=round(experience * 100, 2),
category_modifier=round(cat_modifier, 2),
),
mismatch_analysis=MismatchAnalysis(
reason=reason,
matched_technical_skills=matched_technical,
missing_technical_skills=missing_technical[:10],
matched_soft_skills=matched_soft,
missing_soft_skills=missing_soft[:10],
cv_extra_strengths=extra,
seniority_fit=sen_reason,
category_fit=f"CV category: {cv_category}; Job category: {job.category}.",
improvement_advice=_build_advice(missing_technical, job, cv_category),
),
)
return prediction.model_dump()
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _build_job_index_text(self, job: JobInput) -> str:
"""Build the semantic index text for a job (same as before, factored out)."""
text = (
f"{job.job_title}\nCompany: {job.company_name}\n{job.description}\n"
f"Technical skills: {', '.join(job.skills_technical)}\n"
f"Soft skills: {', '.join(job.skills_soft)}"
)
return text[: self._JOB_TEXT_MAX_CHARS]
def _embed_batch_texts(self, texts: List[str]) -> np.ndarray:
"""
Embed a list of texts in a SINGLE batched encode() call.
No manual word-chunking: bge-small-en-v1.5 supports sequences up
to 512 tokens, and job text is already truncated to
_JOB_TEXT_MAX_CHARS chars upstream, so it fits in one forward
pass per item.
"""
if not texts:
return np.zeros((0, 384), dtype="float32")
safe_texts = [t if t else "" for t in texts]
vectors = self._model.encode(
safe_texts,
normalize_embeddings=True,
show_progress_bar=False,
batch_size=32,
)
return np.asarray(vectors, dtype="float32")
def _embed(self, text: str) -> np.ndarray:
"""Encode a single text → normalised float32 vector (single forward pass)."""
return self._embed_batch_texts([text])[0]
def _embed_title_batch(self, titles: List[str]) -> np.ndarray:
"""Encode a list of titles in ONE batched encode() call with the title model."""
if not titles:
return np.zeros((0, 384), dtype="float32")
vectors = self._title_model.encode(
titles, normalize_embeddings=True, show_progress_bar=False, batch_size=32
)
return np.asarray(vectors).astype("float32")
def _embed_title(self, text: str) -> np.ndarray:
"""Encode a single title using the title model (used once per CV)."""
vector = self._title_model.encode(
text, normalize_embeddings=True, show_progress_bar=False
)
return vector.astype("float32")
def _semantic_skill_matches_precomputed(
self,
missing_skills: List[str],
missing_skill_vecs: Dict[str, np.ndarray],
cv_skills_vecs: np.ndarray,
threshold: float = 0.72,
) -> Set[str]:
"""
Same semantics as the original _semantic_skill_matches, but takes
precomputed vectors for the missing skills (built once per request
in a single batched encode() call in predict_batch) instead of
encoding them again per job.
"""
if not missing_skills or len(cv_skills_vecs) == 0 or not missing_skill_vecs:
return set()
matched: Set[str] = set()
for skill in missing_skills:
vec = missing_skill_vecs.get(skill)
if vec is None:
continue
sims = np.dot(cv_skills_vecs, vec)
if np.max(sims) >= threshold:
matched.add(skill)
return matched
def _title_similarity_precomputed(
self,
cv_title: str,
cv_title_vec: Optional[np.ndarray],
job_title_vec: np.ndarray,
) -> float:
"""
Same semantics as the original _title_similarity, but uses the
CV title vector cached once in cv_data and the job title vector
computed once per request in the batched title encode() call —
no model calls happen here.
"""
if not cv_title or cv_title == "Unknown" or cv_title_vec is None:
return 0.35
return max(0.2, min(1.0, _cosine_similarity(cv_title_vec, job_title_vec)))