File size: 12,732 Bytes
1177cc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""
recommender.py
──────────────
Student recommendations, moved here from ifind/lib/recommendation so everything that touches vectors and the
HNSW graph lives in one service.

For one student: run their BERT vector through the WHOLE graph (k-NN), fetch the hit internships (scraped
'internships' and employer-posted 'internships.this-platform'), score each one with the weighted cosine
(0.4 TF-IDF + 0.6 BERT, vectors are L2-normalised so the dot product is the cosine), drop scores under the
threshold, keep the top N and save them on the user as `recommendedInternships`.

Triggered by (see app.py):
  - /encode-resume with user_id + recommend   a student's resume or profile changed
  - /vectorize-hnsw, /vectorize-platform      new internships entered the graph -> every student is re-run
  - /recommend-all                            admin refresh (all students, or the given user_ids)
"""

import logging
import threading
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Tuple

import numpy as np

from vectorizer_hnsw_pipeline import (
    APPROVED_STATUSES, LIVE_COLLECTION, PLATFORM_COLLECTION, deadline_passed, get_db, get_search_manager, purge_expired,
)

log = logging.getLogger("recommender")

TOP_N = 20
THRESHOLD = 0.1
# Small nudges for the student's location (the profile "preferences" city / state): they only reorder close matches.
CITY_BOOST = 0.08
STATE_BOOST = 0.04
REMOTE_BOOST = 0.03
TFIDF_WEIGHT = 0.4
BERT_WEIGHT = 0.6
K = TOP_N * 2 * 2   # candidates pulled from the graph (same as the old HNSWStrategy: topN*2, doubled)

# One fan-out at a time; requests that arrive meanwhile are folded into one extra pass at the end.
_FANOUT_LOCK = threading.Lock()
_fanout_dirty = False


def _visible_platform_filter() -> Dict[str, Any]:
    """Same rule as ifind lib/employer/visibility.ts visibleToStudentsFilter()."""
    now = datetime.now(timezone.utc)
    return {
        "status": "published",
        "moderation.status": {"$in": list(APPROVED_STATUSES)},
        "isActive": {"$ne": False},
        "bannedByCompany": {"$ne": True},   # company banned by a moderator (ifind companies.restriction.status == "banned")
        "$or": [{"deadlineDate": None}, {"deadlineDate": {"$gt": now}}],
    }


def _norm(v) -> str:
    return str(v or "").strip().lower()


def location_boost(location: Optional[Dict[str, Any]], is_remote: bool, addresses: Optional[list]) -> float:
    """Boost for an internship that is remote, or in the student's city / state. 0 when the student has no location."""
    if not location:
        return 0.0
    city, state = _norm(location.get("city")), _norm(location.get("state"))
    if not city and not state:
        return 0.0
    boost = REMOTE_BOOST if is_remote else 0.0
    for a in addresses or []:
        if not isinstance(a, dict):
            continue
        if city and _norm(a.get("city")) == city:
            boost = max(boost, CITY_BOOST)
        elif state and _norm(a.get("state")) == state:
            boost = max(boost, STATE_BOOST)
    return boost


class _Candidates:
    """Candidate vectors by id, kept across users in one run (the same internships come up again and again)."""

    def __init__(self, db):
        self.db = db
        self.cache: Dict[str, Optional[Tuple[np.ndarray, np.ndarray]]] = {}
        self.where: Dict[str, Tuple[bool, list]] = {}   # id -> (isRemote, address[]) for the location boost

    def get(self, ids: Iterable[str]) -> Dict[str, Tuple[np.ndarray, np.ndarray]]:
        from bson import ObjectId

        ids = list(ids)
        missing = [i for i in ids if i not in self.cache]
        if missing:
            oids = [ObjectId(i) for i in missing]
            proj = {"tfidf_vector": 1, "bert_vector": 1, "isRemote": 1, "address": 1, "deadlineDate": 1}
            now = datetime.now(timezone.utc)
            found: Dict[str, Tuple[np.ndarray, np.ndarray]] = {}
            live = {"_id": {"$in": oids}, "$or": [{"isActive": True}, {"isActive": {"$exists": False}}]}
            plat = {**_visible_platform_filter(), "_id": {"$in": oids}}
            for col, q in ((LIVE_COLLECTION, live), (PLATFORM_COLLECTION, plat)):
                for d in self.db[col].find(q, proj):
                    if deadline_passed(d.get("deadlineDate"), now):
                        continue   # expired: never recommended, whatever the graph still holds
                    if isinstance(d.get("bert_vector"), list) and isinstance(d.get("tfidf_vector"), list):
                        self.where[str(d["_id"])] = (bool(d.get("isRemote")), d.get("address") if isinstance(d.get("address"), list) else [])
                        found[str(d["_id"])] = (
                            np.asarray(d["tfidf_vector"], dtype=np.float32),
                            np.asarray(d["bert_vector"], dtype=np.float32),
                        )
            for i in missing:
                self.cache[i] = found.get(i)   # None = not recommendable right now (inactive, paused, no vectors)
        return {i: self.cache[i] for i in ids if self.cache.get(i) is not None}


def _brute_force_ids(db) -> List[str]:
    """Graph unavailable: every recommendable internship (the old BruteForceStrategy). ponytail: O(n), fine as a fallback."""
    ids = [str(d["_id"]) for d in db[LIVE_COLLECTION].find(
        {"$or": [{"isActive": True}, {"isActive": {"$exists": False}}], "bert_vector": {"$type": "array"}}, {"_id": 1, "deadlineDate": 1})
        if not deadline_passed(d.get("deadlineDate"))]
    ids += [str(d["_id"]) for d in db[PLATFORM_COLLECTION].find(
        {**_visible_platform_filter(), "bert_vector": {"$type": "array"}}, {"_id": 1, "deadlineDate": 1})
        if not deadline_passed(d.get("deadlineDate"))]
    return ids


def score_user(db, mgr, cands: _Candidates, tfidf: List[float], bert: List[float], location: Optional[Dict[str, Any]] = None) -> List[Tuple[str, float]]:
    """Run one student's vectors through the graph and return the top N (id, score), best first."""
    try:
        ids = [i for i, _ in mgr.search(bert, K)]
    except Exception as err:
        log.error("Graph search failed, falling back to a full scan: %s", err)
        ids = _brute_force_ids(db)

    u_tfidf = np.asarray(tfidf, dtype=np.float32)
    u_bert = np.asarray(bert, dtype=np.float32)
    scored = []
    for i, (c_tfidf, c_bert) in cands.get(ids).items():
        t = float(u_tfidf @ c_tfidf) if c_tfidf.shape == u_tfidf.shape else 0.0
        b = float(u_bert @ c_bert) if c_bert.shape == u_bert.shape else 0.0
        score = t * TFIDF_WEIGHT + b * BERT_WEIGHT
        if score >= THRESHOLD:
            remote, addresses = cands.where.get(i, (False, []))
            scored.append((i, score + location_boost(location, remote, addresses)))
    scored.sort(key=lambda x: x[1], reverse=True)
    return scored[:TOP_N]


def save_recommendations(db, user_id, recs: List[Tuple[str, float]]) -> None:
    from bson import ObjectId

    db["users"].update_one(
        {"_id": ObjectId(str(user_id))},
        {"$set": {
            "recommendedInternships.updatedAt": datetime.now(timezone.utc),
            "recommendedInternships.recommendedList": [ObjectId(i) for i, _ in recs],
            "recommendedInternships.recommendedScores": [{"id": ObjectId(i), "score": round(s, 3)} for i, s in recs],
        }},
    )


def recommend_user(user_id: str, tfidf: List[float], bert: List[float], db=None) -> int:
    """Run ONE student through the whole graph and save their list. Returns how many were recommended."""
    db = db if db is not None else get_db()
    from bson import ObjectId

    mgr = get_search_manager(db, dim=len(bert))
    graph_count = mgr.index.get_current_count() if mgr and mgr.index else len(mgr.uid_to_id)
    user_doc = db["users"].find_one({"_id": ObjectId(str(user_id))}, {"username": 1, "email": 1, "name": 1, "city": 1, "state": 1})
    username = user_doc.get("username") or user_doc.get("email") or user_doc.get("name") or str(user_id) if user_doc else str(user_id)

    log.info("Running recommender for student '%s' (id=%s) against %d graph internships...",
             username, user_id, graph_count)
    recs = score_user(db, mgr, _Candidates(db), tfidf, bert, user_doc)
    save_recommendations(db, user_id, recs)
    top_score = round(recs[0][1], 3) if recs else 0.0
    log.ok("Recommended %d internships for student '%s' (top match: %.3f)", len(recs), username, top_score)
    return len(recs)


def recommend_users(user_ids: Optional[List[str]] = None, db=None) -> Dict[str, int]:
    """Run every student with resume vectors (or just `user_ids`) through the whole graph."""
    from bson import ObjectId

    db = db if db is not None else get_db()
    stats = {"users": 0, "updated": 0, "errors": 0}
    query: Dict[str, Any] = {"resume.bert_vector": {"$type": "array"}, "resume.tfidf_vector": {"$type": "array"}}
    if user_ids is not None:
        oids = []
        for u in user_ids:
            try:
                oids.append(ObjectId(u))
            except Exception:
                stats["errors"] += 1
        query["_id"] = {"$in": oids}

    mgr = get_search_manager(db)
    graph_count = mgr.index.get_current_count() if mgr and mgr.index else (len(mgr.uid_to_id) if mgr else 0)
    total_users = db["users"].count_documents(query)

    target_desc = f"{total_users} specified student(s)" if user_ids is not None else f"every student ({total_users} with active resumes)"
    log.info("Running recommender fan-out on %s against %d graph internships (top %d per student)...",
             target_desc, graph_count, TOP_N)

    if total_users == 0:
        log.info("No matching students found in database β€” 0 recommendations needed")
        return stats

    cands = _Candidates(db)
    cursor = db["users"].find(
        query,
        {"username": 1, "email": 1, "name": 1, "resume.tfidf_vector": 1, "resume.bert_vector": 1, "city": 1, "state": 1}
    )

    idx = 0
    for user in cursor:
        idx += 1
        stats["users"] += 1
        username = user.get("username") or user.get("email") or user.get("name") or str(user["_id"])
        try:
            bert = user["resume"]["bert_vector"]
            if mgr is None or mgr.dim != len(bert):
                mgr = get_search_manager(db, dim=len(bert))
            recs = score_user(db, mgr, cands, user["resume"]["tfidf_vector"], bert, user)
            save_recommendations(db, user["_id"], recs)
            stats["updated"] += 1
            top_score = round(recs[0][1], 3) if recs else 0.0
            log.info("[%d/%d] Recommended for student '%s': %d internships matched (top score: %.3f)",
                     idx, total_users, username, len(recs), top_score)
        except Exception as err:
            log.error("[%d/%d] Recommendation failed for student '%s' (id=%s): %s",
                      idx, total_users, username, user.get("_id"), err)
            stats["errors"] += 1

    if stats["errors"] == 0:
        log.ok("Recommender complete: %d/%d students updated successfully, 0 failed (graph size: %d internships)",
               stats["updated"], stats["users"], graph_count)
    else:
        log.warn("Recommender finished with issues: %d updated, %d failed out of %d students",
                 stats["updated"], stats["errors"], stats["users"])
    return stats


def run_fanout(user_ids: Optional[List[str]] = None) -> Dict[str, Any]:
    """
    recommend_users() with coalescing: when one is already running, ask it to go once more when it finishes
    instead of piling up (each approval in a moderator batch would otherwise start its own pass).
    A targeted run (user_ids) is never coalesced.
    """
    global _fanout_dirty
    try:
        purge_expired()   # expired internships leave the collection and the graph before anyone is scored
    except Exception as err:
        log.error("Expiry sweep failed (the recommender still skips expired internships): %s", err)
    if user_ids is not None:
        return recommend_users(user_ids)
    if not _FANOUT_LOCK.acquire(blocking=False):
        _fanout_dirty = True
        log.info("Recommender fan-out already in progress β€” marked dirty to re-run on completion")
        return {"status": "coalesced"}
    try:
        pass_count = 1
        while True:
            _fanout_dirty = False
            log.info("Starting recommender fan-out pass #%d on all students...", pass_count)
            stats = recommend_users()
            if not _fanout_dirty:
                return stats
            pass_count += 1
            log.info("Fan-out marked dirty during pass #%d, running subsequent pass...", pass_count - 1)
    finally:
        _FANOUT_LOCK.release()