Spaces:
Running
Running
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()
|