File size: 3,372 Bytes
c8f4a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
"""
Embedding service: compute semantic similarity between resume and JD.

Strategy (two-tier):
  1. Try sentence-transformers (all-MiniLM-L6-v2) for quality embeddings.
  2. Fall back to TF-IDF cosine similarity (sklearn) if model unavailable.

The model is loaded lazily so the server starts instantly even if the
first analysis takes a few extra seconds.
"""
from __future__ import annotations

import logging
import threading
from typing import Any

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

logger = logging.getLogger(__name__)

# ── Lazy sentence-transformers loader ────────────────────────────────────


_model_lock = threading.Lock()
_model: Any = None          # SentenceTransformer or None
_model_loaded = False       # whether we've attempted loading

_ST_MODEL = "all-MiniLM-L6-v2"


def _try_load_st_model() -> Any | None:
    """Attempt to load the SentenceTransformer model once."""
    global _model, _model_loaded
    with _model_lock:
        if _model_loaded:
            return _model
        _model_loaded = True
        try:
            from sentence_transformers import SentenceTransformer  # noqa: PLC0415
            logger.info("Loading SentenceTransformer model %s …", _ST_MODEL)
            _model = SentenceTransformer(_ST_MODEL)
            logger.info("SentenceTransformer model ready.")
        except Exception as exc:
            logger.warning("SentenceTransformer unavailable (%s); using TF-IDF fallback.", exc)
            _model = None
        return _model


# ── Public API ────────────────────────────────────────────────────────────


def compute_similarity(text_a: str, text_b: str) -> float:
    """
    Return a cosine similarity score in [0, 1] between two texts.
    Uses sentence-transformers if available, else TF-IDF.
    """
    if not text_a.strip() or not text_b.strip():
        return 0.0

    model = _try_load_st_model()
    if model is not None:
        return _st_similarity(model, text_a, text_b)
    return _tfidf_similarity(text_a, text_b)


def compute_section_similarities(
    sections: dict[str, str], jd_text: str
) -> dict[str, float]:
    """
    Compute per-section similarity against the JD.
    Returns dict of {section_name: score_0_to_1}.
    """
    results: dict[str, float] = {}
    for name, content in sections.items():
        if content.strip():
            results[name] = compute_similarity(content, jd_text)
    return results


def _st_similarity(model: Any, a: str, b: str) -> float:
    embs = model.encode([a, b], convert_to_numpy=True)
    score = cosine_similarity(embs[0:1], embs[1:2])[0][0]
    return float(np.clip(score, 0.0, 1.0))


def _tfidf_similarity(a: str, b: str) -> float:
    try:
        vec = TfidfVectorizer(
            ngram_range=(1, 2),
            stop_words="english",
            max_features=8000,
        )
        mat = vec.fit_transform([a, b])
        score = cosine_similarity(mat[0:1], mat[1:2])[0][0]
        return float(np.clip(score, 0.0, 1.0))
    except Exception as exc:
        logger.error("TF-IDF similarity failed: %s", exc)
        return 0.0