Spaces:
Running
Running
Deploy Lighthouse Streamlit app (docker space)
#1
by Auenchanters - opened
- Dockerfile +18 -0
- README.md +20 -10
- app/app.py +132 -0
- app/sample_candidates.jsonl +0 -0
- artifacts/jd_facet_emb.npy +3 -0
- artifacts/jd_rubric.json +179 -0
- lighthouse/__init__.py +10 -0
- lighthouse/features.py +232 -0
- lighthouse/gates.py +146 -0
- lighthouse/honeypot.py +94 -0
- lighthouse/loader.py +196 -0
- lighthouse/metrics.py +65 -0
- lighthouse/ranker.py +94 -0
- lighthouse/reasoning.py +169 -0
- lighthouse/scoring.py +175 -0
- requirements.txt +7 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lighthouse — self-contained sandbox image (also used by the HuggingFace Docker Space).
|
| 2 |
+
# Runs the Streamlit demo on CPU. For the full 100K rank, use rank.py (see README).
|
| 3 |
+
FROM python:3.10-slim
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# CPU-only torch first (kept out of the CUDA default to keep the image small)
|
| 8 |
+
RUN pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cpu
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# HuggingFace Spaces expects the app on 7860
|
| 16 |
+
ENV PORT=7860
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
CMD ["streamlit", "run", "app/app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Lighthouse
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Lighthouse Candidate Ranker
|
| 3 |
+
emoji: 🔦
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# 🔦 Lighthouse - candidate ranker sandbox
|
| 13 |
+
|
| 14 |
+
Live demo of the Lighthouse recruiter-grade candidate ranker (Redrob India Runs challenge).
|
| 15 |
+
Upload up to 100 candidates (or use the preloaded 100-candidate sample) and Lighthouse ranks
|
| 16 |
+
them end-to-end on CPU - five-component fit score, JD hard-negative gates, behavioral modifier,
|
| 17 |
+
honeypot zeroing, grounded reasoning - then download the ranked CSV.
|
| 18 |
+
|
| 19 |
+
Uploaded candidates are encoded on the fly with BAAI/bge-small-en-v1.5. Full code:
|
| 20 |
+
https://github.com/jacklachan/lighthouse-indiaruns
|
app/app.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lighthouse — HuggingFace Spaces / Streamlit sandbox.
|
| 2 |
+
|
| 3 |
+
Accepts up to 100 candidates (preloaded sample or uploaded JSONL), runs the full
|
| 4 |
+
Lighthouse ranker end-to-end on CPU, and shows the ranked table with scores +
|
| 5 |
+
grounded reasoning. Candidates uploaded here are not precomputed, so the small
|
| 6 |
+
bge-small encoder runs at request time (a few seconds for <=100 rows) — this is
|
| 7 |
+
the demo path; the official 100K run uses fully precomputed embeddings.
|
| 8 |
+
|
| 9 |
+
Run locally: streamlit run app/app.py
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import streamlit as st
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 20 |
+
|
| 21 |
+
from lighthouse import loader, ranker, reasoning # noqa: E402
|
| 22 |
+
|
| 23 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 24 |
+
ROOT = os.path.dirname(HERE)
|
| 25 |
+
ART = os.path.join(ROOT, "artifacts")
|
| 26 |
+
SAMPLE = os.path.join(HERE, "sample_candidates.jsonl")
|
| 27 |
+
|
| 28 |
+
st.set_page_config(page_title="Lighthouse — Candidate Ranker", page_icon="🔦", layout="wide")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@st.cache_resource
|
| 32 |
+
def _facets_and_rubric():
|
| 33 |
+
rubric = json.load(open(os.path.join(ART, "jd_rubric.json"), encoding="utf-8"))
|
| 34 |
+
facet_emb = np.load(os.path.join(ART, "jd_facet_emb.npy"))
|
| 35 |
+
return rubric, facet_emb
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _build_art(rubric, facet_emb):
|
| 39 |
+
"""Empty precomputed set -> every candidate is encoded on the fly."""
|
| 40 |
+
dim = facet_emb.shape[1]
|
| 41 |
+
return {"rubric": rubric, "ids": [], "id_to_row": {},
|
| 42 |
+
"cand_emb": np.zeros((0, dim), dtype=np.float32), "facet_emb": facet_emb}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _parse_jsonl(text: str):
|
| 46 |
+
raws = []
|
| 47 |
+
for line in text.splitlines():
|
| 48 |
+
line = line.strip()
|
| 49 |
+
if not line:
|
| 50 |
+
continue
|
| 51 |
+
try:
|
| 52 |
+
raws.append(json.loads(line))
|
| 53 |
+
except json.JSONDecodeError:
|
| 54 |
+
pass
|
| 55 |
+
return raws
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
st.title("🔦 Lighthouse — recruiter-grade candidate ranker")
|
| 59 |
+
st.caption("Keyword filters surface the loudest profiles. Lighthouse surfaces the right ones — "
|
| 60 |
+
"and ignores the fakes that fool keyword filters.")
|
| 61 |
+
|
| 62 |
+
with st.sidebar:
|
| 63 |
+
st.header("Input")
|
| 64 |
+
mode = st.radio("Candidate source", ["Preloaded sample (100)", "Upload JSONL (≤100)"])
|
| 65 |
+
st.markdown("---")
|
| 66 |
+
st.markdown("**JD:** Senior AI Engineer @ Redrob AI — production embeddings/retrieval, "
|
| 67 |
+
"ranking-eval, product (not services), 6–8 yrs ideal, Noida/Pune/India.")
|
| 68 |
+
st.markdown("Ranking runs on **CPU, no hosted LLM**. The five-component score is gated by "
|
| 69 |
+
"JD hard-negatives and a behavioral modifier; honeypots are zeroed.")
|
| 70 |
+
|
| 71 |
+
rubric, facet_emb = _facets_and_rubric()
|
| 72 |
+
|
| 73 |
+
raws = []
|
| 74 |
+
if mode.startswith("Preloaded"):
|
| 75 |
+
if os.path.exists(SAMPLE):
|
| 76 |
+
raws = _parse_jsonl(open(SAMPLE, encoding="utf-8").read())
|
| 77 |
+
st.info(f"Loaded {len(raws)} preloaded sample candidates.")
|
| 78 |
+
else:
|
| 79 |
+
st.error("sample_candidates.jsonl not found.")
|
| 80 |
+
else:
|
| 81 |
+
up = st.file_uploader("Upload candidates JSONL (one JSON candidate per line)", type=["jsonl", "json"])
|
| 82 |
+
if up is not None:
|
| 83 |
+
raws = _parse_jsonl(up.read().decode("utf-8"))
|
| 84 |
+
st.info(f"Parsed {len(raws)} candidates.")
|
| 85 |
+
|
| 86 |
+
raws = raws[:100]
|
| 87 |
+
|
| 88 |
+
if raws and st.button("🔦 Rank candidates", type="primary"):
|
| 89 |
+
with st.spinner(f"Encoding + scoring {len(raws)} candidates on CPU ..."):
|
| 90 |
+
art = _build_art(rubric, facet_emb)
|
| 91 |
+
records = ranker.score_all(raws, art)
|
| 92 |
+
mx = max((r["final_score"] for r in records), default=0.0)
|
| 93 |
+
if mx > 0:
|
| 94 |
+
for r in records:
|
| 95 |
+
r["final_score"] = round(r["final_score"] / mx, 6)
|
| 96 |
+
top = ranker.rank_records(records, top=len(records))
|
| 97 |
+
raw_by_id = {loader.candidate_id(r): r for r in raws}
|
| 98 |
+
rows = []
|
| 99 |
+
for rec in top:
|
| 100 |
+
raw = raw_by_id[rec["candidate_id"]]
|
| 101 |
+
p = loader.get_profile(raw)
|
| 102 |
+
rows.append({
|
| 103 |
+
"rank": rec["rank"],
|
| 104 |
+
"candidate_id": rec["candidate_id"],
|
| 105 |
+
"score": rec["final_score"],
|
| 106 |
+
"title": loader._s(p, "current_title"),
|
| 107 |
+
"country": loader._s(p, "country"),
|
| 108 |
+
"yrs": loader._f(p, "years_of_experience"),
|
| 109 |
+
"honeypot": "⚠️" if rec["honeypot"] else "",
|
| 110 |
+
"reasoning": reasoning.generate(raw, rubric, rec),
|
| 111 |
+
})
|
| 112 |
+
df = pd.DataFrame(rows)
|
| 113 |
+
n_hp = sum(1 for r in top if r["honeypot"])
|
| 114 |
+
c1, c2, c3 = st.columns(3)
|
| 115 |
+
c1.metric("Candidates ranked", len(rows))
|
| 116 |
+
c2.metric("Honeypots flagged", n_hp)
|
| 117 |
+
c3.metric("Top score", f"{rows[0]['score']:.3f}" if rows else "—")
|
| 118 |
+
st.dataframe(df, use_container_width=True, hide_index=True,
|
| 119 |
+
column_config={"score": st.column_config.NumberColumn(format="%.4f")})
|
| 120 |
+
|
| 121 |
+
with st.expander("Inspect the top candidate's component breakdown"):
|
| 122 |
+
best = top[0]
|
| 123 |
+
st.json({"candidate_id": best["candidate_id"], "components": best["components"],
|
| 124 |
+
"base": best["base"], "gate_mult": best["gate_mult"],
|
| 125 |
+
"gate_reasons": best["gate_reasons"], "behavior_mult": best["behavior_mult"],
|
| 126 |
+
"honeypot": best["honeypot"]})
|
| 127 |
+
|
| 128 |
+
st.download_button("⬇️ Download ranking CSV",
|
| 129 |
+
df[["candidate_id", "rank", "score", "reasoning"]].to_csv(index=False),
|
| 130 |
+
file_name="submission.csv", mime="text/csv")
|
| 131 |
+
elif not raws:
|
| 132 |
+
st.warning("Choose the preloaded sample or upload a JSONL to begin.")
|
app/sample_candidates.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/jd_facet_emb.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a6b3d7b669928bc60e95057d092ba8bdac5c462b11f42d02a085cb314ed58e42
|
| 3 |
+
size 15488
|
artifacts/jd_rubric.json
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_meta": {
|
| 3 |
+
"title": "Senior AI Engineer — Founding Team @ Redrob AI",
|
| 4 |
+
"authored_by": "Claude (offline, reading job_description.docx)",
|
| 5 |
+
"authored_at": "2026-06-06",
|
| 6 |
+
"reference_date": "2026-06-06",
|
| 7 |
+
"note": "This rubric is a STATIC, committed artifact. rank.py reads it at rank-time; no LLM/API call is made during ranking. It encodes the gap between what the JD literally says and what it means, per the JD's own 'how to read between the lines' and 'final note for hackathon participants' sections."
|
| 8 |
+
},
|
| 9 |
+
|
| 10 |
+
"experience": {
|
| 11 |
+
"band_min": 5.0,
|
| 12 |
+
"band_max": 9.0,
|
| 13 |
+
"ideal_min": 6.0,
|
| 14 |
+
"ideal_max": 8.0,
|
| 15 |
+
"rationale": "JD: '5-9 years ... a range, not a requirement', ideal '6-8 years total, of which 4-5 in applied ML/AI at product companies'. Soft curve: peak inside ideal, gentle taper across the band, steeper outside. Strong signals can rescue out-of-band candidates, so this is a score component, not a hard gate.",
|
| 16 |
+
"soft": true
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
"facets": [
|
| 20 |
+
"Production experience with embeddings-based retrieval systems (sentence-transformers, OpenAI embeddings, BGE, E5) deployed to real users, handling embedding drift and retrieval-quality regression.",
|
| 21 |
+
"Production experience with vector databases or hybrid search infrastructure: Pinecone, Weaviate, Qdrant, Milvus, FAISS, OpenSearch, Elasticsearch.",
|
| 22 |
+
"Designed and ran evaluation frameworks for ranking systems: NDCG, MRR, MAP, offline-to-online correlation, A/B test interpretation.",
|
| 23 |
+
"Built and shipped an end-to-end ranking, search, or recommendation system to real users at meaningful scale.",
|
| 24 |
+
"Hybrid retrieval and re-ranking: dense vs sparse retrieval, learning-to-rank, LLM-based re-ranking, relevance tuning.",
|
| 25 |
+
"Applied machine learning at a product company: feature engineering through deployment, owning a model in production.",
|
| 26 |
+
"Information retrieval and NLP: semantic search, query understanding, text embeddings, retrieval relevance.",
|
| 27 |
+
"Strong Python engineering with attention to code quality.",
|
| 28 |
+
"Learning-to-rank models with XGBoost, LightGBM, or neural rankers over click-through and human-judgement labels.",
|
| 29 |
+
"Scrappy product-engineering: shipping a working ranker fast and iterating from real user feedback, comfortable owning the intelligence/matching layer of a product."
|
| 30 |
+
],
|
| 31 |
+
|
| 32 |
+
"must_haves": [
|
| 33 |
+
{"key": "embeddings_retrieval", "label": "Production embeddings-based retrieval", "keywords": ["embedding", "embeddings", "sentence transformer", "sentence-transformers", "bge", "e5", "dense retrieval", "vector search", "semantic search", "openai embeddings"]},
|
| 34 |
+
{"key": "vector_search_infra", "label": "Vector DB / hybrid search infra", "keywords": ["pinecone", "weaviate", "qdrant", "milvus", "faiss", "opensearch", "elasticsearch", "vector database", "hybrid search", "ann index", "hnsw"]},
|
| 35 |
+
{"key": "ranking_eval", "label": "Ranking evaluation frameworks", "keywords": ["ndcg", "mrr", "map", "mean average precision", "offline-online", "offline to online", "a/b test", "ab test", "relevance label", "click-through", "ctr", "evaluation framework", "learning to rank", "learning-to-rank"]},
|
| 36 |
+
{"key": "shipped_ranking_system", "label": "Shipped a ranking/search/recsys system", "keywords": ["ranking", "search", "recommendation", "recommender", "recsys", "retrieval", "relevance", "personalization", "discovery feed", "matching"]},
|
| 37 |
+
{"key": "strong_python", "label": "Strong Python", "keywords": ["python", "pytorch", "tensorflow", "scikit-learn", "numpy", "pandas"]}
|
| 38 |
+
],
|
| 39 |
+
|
| 40 |
+
"nice_to_haves": [
|
| 41 |
+
{"key": "llm_finetuning", "label": "LLM fine-tuning (LoRA/QLoRA/PEFT)", "keywords": ["lora", "qlora", "peft", "fine-tun", "fine tun", "instruction tuning", "rlhf"]},
|
| 42 |
+
{"key": "ltr_models", "label": "Learning-to-rank models", "keywords": ["xgboost", "lightgbm", "learning to rank", "learning-to-rank", "lambdamart", "ranknet"]},
|
| 43 |
+
{"key": "hrtech", "label": "HR-tech / recruiting / marketplace", "keywords": ["recruit", "hr-tech", "hrtech", "talent", "marketplace", "two-sided"]},
|
| 44 |
+
{"key": "distributed_inference", "label": "Distributed systems / large-scale inference", "keywords": ["distributed", "large-scale inference", "low latency", "serving", "throughput", "scale"]},
|
| 45 |
+
{"key": "open_source", "label": "Open-source contributions", "keywords": ["open source", "open-source", "github", "maintainer", "contributor", "published", "paper", "talk"]}
|
| 46 |
+
],
|
| 47 |
+
|
| 48 |
+
"role_taxonomy": {
|
| 49 |
+
"positive_title_terms": [
|
| 50 |
+
"machine learning", "ml engineer", "ml scientist", "ai engineer", "applied scientist",
|
| 51 |
+
"applied ml", "data scientist", "research engineer", "nlp", "search engineer",
|
| 52 |
+
"ranking", "recommendation", "recsys", "deep learning", "software engineer",
|
| 53 |
+
"backend engineer", "data engineer", "mlops", "platform engineer", "staff engineer",
|
| 54 |
+
"research scientist", "information retrieval", "relevance engineer", "full stack"
|
| 55 |
+
],
|
| 56 |
+
"strong_positive_title_terms": [
|
| 57 |
+
"machine learning", "ml engineer", "ai engineer", "applied scientist", "applied ml",
|
| 58 |
+
"nlp", "search engineer", "ranking", "recommendation", "recsys", "research engineer",
|
| 59 |
+
"data scientist", "relevance engineer", "information retrieval"
|
| 60 |
+
],
|
| 61 |
+
"negative_title_terms": [
|
| 62 |
+
"marketing", "hr ", "human resources", "accountant", "accounting", "sales",
|
| 63 |
+
"operations manager", "graphic", "designer", "civil engineer", "mechanical engineer",
|
| 64 |
+
"content writer", "customer support", "recruiter", "business analyst",
|
| 65 |
+
"project manager", "program manager", "qa engineer", "quality assurance",
|
| 66 |
+
"frontend engineer", "mobile developer", "ui ", "ux "
|
| 67 |
+
],
|
| 68 |
+
"rationale": "role_coherence is the decisive signal against keyword-stuffers. The JD's own example: 'A candidate who has all the AI keywords listed as skills but whose title is Marketing Manager is not a fit, no matter how perfect their skill list looks.' Title alone is noisy, so role_coherence blends taxonomy match of current+historical titles with semantic_fit of the career text."
|
| 69 |
+
},
|
| 70 |
+
|
| 71 |
+
"career_evidence_terms": [
|
| 72 |
+
"ranking", "learning to rank", "learning-to-rank", "search", "semantic search",
|
| 73 |
+
"recommendation", "recommender", "recsys", "retrieval", "information retrieval",
|
| 74 |
+
"relevance", "embedding", "embeddings", "vector", "personalization",
|
| 75 |
+
"discovery feed", "click-through", "offline-online", "a/b test", "ndcg",
|
| 76 |
+
"feature engineering", "production", "shipped", "deployed", "at scale", "real users"
|
| 77 |
+
],
|
| 78 |
+
|
| 79 |
+
"hard_negatives": [
|
| 80 |
+
{
|
| 81 |
+
"key": "services_only",
|
| 82 |
+
"label": "Services/consulting-only career (no product company)",
|
| 83 |
+
"companies": ["tcs", "tata consultancy", "infosys", "wipro", "accenture", "cognizant", "capgemini", "tech mahindra", "hcl", "mindtree", "ltimindtree", "mphasis", "igate", "syntel", "l&t infotech", "larsen", "dxc", "ibm global services"],
|
| 84 |
+
"penalty": 0.45,
|
| 85 |
+
"rule": "Apply when EVERY role's company is a services/consulting firm. JD: 'People who have only worked at consulting firms ... in their entire career.' If currently at one but has prior product-company experience, do NOT penalize (JD says that's fine).",
|
| 86 |
+
"rationale": "Multiplicative penalty, not a zero — a services-only candidate is a weak fit but not impossible/fraudulent."
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"key": "location_visa",
|
| 90 |
+
"label": "Outside India and unwilling to relocate (no visa sponsorship)",
|
| 91 |
+
"penalty": 0.30,
|
| 92 |
+
"soft_penalty": 0.80,
|
| 93 |
+
"rule": "country != India AND willing_to_relocate == false -> penalty (0.30). country != India AND willing_to_relocate == true -> soft_penalty (0.80, visa/logistics risk). country == India -> no penalty (JD welcomes Hyderabad/Pune/Mumbai/Delhi-NCR and is flexible within India).",
|
| 94 |
+
"rationale": "JD: 'Outside India: case-by-case, but we don't sponsor work visas.' A Toronto-based candidate who won't relocate (e.g. CAND_0000001) is effectively unhireable here."
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"key": "research_only",
|
| 98 |
+
"label": "Pure research, no production deployment",
|
| 99 |
+
"positive_terms": ["research", "phd", "postdoc", "thesis", "publication", "academic", "professor", "lab", "paper"],
|
| 100 |
+
"production_terms": ["production", "shipped", "deployed", "real users", "at scale", "serving", "product", "launched", "a/b"],
|
| 101 |
+
"penalty": 0.55,
|
| 102 |
+
"rule": "Apply when research signals are strong across the career AND production signals are essentially absent. JD: 'pure research environments ... without any production deployment — we will not move forward.'"
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"key": "cv_speech_only",
|
| 106 |
+
"label": "CV/speech/robotics primary, no NLP/IR",
|
| 107 |
+
"domain_terms": ["computer vision", "image classification", "object detection", "speech recognition", "tts", "asr", "robotics", "gans", "image segmentation", "ocr", "pose estimation"],
|
| 108 |
+
"nlp_ir_terms": ["nlp", "natural language", "information retrieval", "retrieval", "search", "ranking", "embedding", "recommendation", "text", "language model"],
|
| 109 |
+
"penalty": 0.60,
|
| 110 |
+
"rule": "Apply when CV/speech/robotics dominate skills/career AND NLP/IR signals are weak/absent. JD: 'primary expertise is computer vision, speech, or robotics without significant NLP/IR exposure ... you'd be re-learning fundamentals here.'"
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"key": "langchain_only_recent",
|
| 114 |
+
"label": "AI experience only recent LangChain+OpenAI wrappers",
|
| 115 |
+
"wrapper_terms": ["langchain", "llamaindex", "llama-index", "openai api", "prompt engineering", "chatgpt", "gpt wrapper", "autogen"],
|
| 116 |
+
"depth_terms": ["embedding", "retrieval", "ranking", "recommendation", "feature engineering", "xgboost", "lightgbm", "model training", "mlops", "information retrieval", "pre-llm", "classical ml"],
|
| 117 |
+
"penalty": 0.65,
|
| 118 |
+
"rule": "Apply when AI signals are recent (<= ~18 months) and consist of LangChain/OpenAI-wrapper terms WITHOUT substantial pre-LLM-era ML depth. JD: '\"AI experience\" consists primarily of recent (under 12 months) projects using LangChain to call OpenAI ... unless you can demonstrate substantial pre-LLM-era ML production experience.'"
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"key": "title_chaser",
|
| 122 |
+
"label": "Title-chaser (job-hops ~every 1.5 yrs for title bumps)",
|
| 123 |
+
"max_avg_tenure_months": 20,
|
| 124 |
+
"min_roles": 4,
|
| 125 |
+
"penalty": 0.80,
|
| 126 |
+
"rule": "Apply when 4+ roles with average completed tenure < 20 months AND titles escalate (Senior->Staff->Principal pattern). JD: 'optimizing for Senior -> Staff -> Principal titles by switching companies every 1.5 years ... we need someone who plans to be here for 3+ years.' Mild multiplicative penalty."
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"key": "non_technical_role",
|
| 130 |
+
"label": "Non-engineering current role (keyword-stuffer bait)",
|
| 131 |
+
"penalty": 0.25,
|
| 132 |
+
"rule": "Apply when current_title and the whole career trajectory are clearly non-engineering (marketing/HR/accounting/sales/ops/design/civil/mechanical/content/support) regardless of how many AI skills are listed. This is the primary keyword-stuffer trap. Largely overlaps role_coherence; kept as an explicit gate so the reasoning can name it.",
|
| 133 |
+
"rationale": "Strong penalty (not zero): an Accountant listing 9 AI skills is the canonical trap candidate the sample_submission wrongly ranks #1."
|
| 134 |
+
}
|
| 135 |
+
],
|
| 136 |
+
|
| 137 |
+
"behavioral": {
|
| 138 |
+
"reference_date": "2026-06-06",
|
| 139 |
+
"active_recent_days": 60,
|
| 140 |
+
"active_stale_days": 180,
|
| 141 |
+
"good_response_rate": 0.5,
|
| 142 |
+
"weak_response_rate": 0.15,
|
| 143 |
+
"good_interview_completion": 0.6,
|
| 144 |
+
"notice_preferred_days": 30,
|
| 145 |
+
"notice_acceptable_days": 90,
|
| 146 |
+
"modifier_floor": 0.80,
|
| 147 |
+
"modifier_ceiling": 1.12,
|
| 148 |
+
"rationale": "Behavioral signals MODIFY (multiplicatively) a fit score; they never drive it. JD: 'a perfect-on-paper candidate who hasn't logged in for 6 months and has a 5% recruiter response rate is, for hiring purposes, not actually available. Down-weight them appropriately.' Reward recent activity, high recruiter_response_rate, open_to_work, verified contact, healthy interview_completion_rate; penalize stale/unreachable. Capped so behavior can lift a weak base only modestly (ceiling 1.12) but can meaningfully sink an unreachable one (floor 0.80). Sentinels (-1 github, -1 offer, empty assessments) are COMMON (>60% of pool) and are treated as neutral, never penalized."
|
| 149 |
+
},
|
| 150 |
+
|
| 151 |
+
"ai_core_skills": [
|
| 152 |
+
"nlp", "embeddings", "information retrieval", "sentence transformers", "hugging face transformers",
|
| 153 |
+
"faiss", "pinecone", "milvus", "weaviate", "qdrant", "machine learning", "deep learning",
|
| 154 |
+
"fine-tuning llms", "lora", "rag", "vector search", "recommendation systems", "learning to rank",
|
| 155 |
+
"xgboost", "lightgbm", "pytorch", "tensorflow", "scikit-learn", "mlops", "mlflow",
|
| 156 |
+
"feature engineering", "llms", "transformers", "semantic search", "retrieval", "speech recognition",
|
| 157 |
+
"image classification", "gans", "tts", "statistical modeling"
|
| 158 |
+
],
|
| 159 |
+
"jd_relevant_skills": [
|
| 160 |
+
"nlp", "embeddings", "information retrieval", "sentence transformers", "hugging face transformers",
|
| 161 |
+
"faiss", "pinecone", "milvus", "weaviate", "qdrant", "machine learning", "vector search",
|
| 162 |
+
"recommendation systems", "learning to rank", "xgboost", "lightgbm", "mlops", "mlflow",
|
| 163 |
+
"feature engineering", "semantic search", "retrieval", "fine-tuning llms", "rag", "transformers"
|
| 164 |
+
],
|
| 165 |
+
"off_target_skills": [
|
| 166 |
+
"speech recognition", "tts", "asr", "image classification", "object detection", "gans",
|
| 167 |
+
"ocr", "robotics", "photoshop", "tailwind", "react", "angular", "javascript", "typescript",
|
| 168 |
+
"marketing", "seo", "project management"
|
| 169 |
+
],
|
| 170 |
+
|
| 171 |
+
"component_weights": {
|
| 172 |
+
"_comment": "Base weighted sum of the five components, tuned on the Claude-authored eval set to maximise NDCG@10. role_coherence + career_evidence dominate because they are what separate true fits from keyword-stuffers.",
|
| 173 |
+
"semantic_fit": 0.22,
|
| 174 |
+
"role_coherence": 0.26,
|
| 175 |
+
"career_evidence": 0.24,
|
| 176 |
+
"experience_fit": 0.10,
|
| 177 |
+
"trust_skills": 0.18
|
| 178 |
+
}
|
| 179 |
+
}
|
lighthouse/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lighthouse — a recruiter-grade, reasoning-first candidate ranker.
|
| 2 |
+
|
| 3 |
+
Keyword filters surface the loudest profiles. Lighthouse surfaces the right
|
| 4 |
+
ones — and ignores the fakes that fool keyword filters.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = "1.0.0"
|
| 8 |
+
|
| 9 |
+
# Single source of truth for determinism across the whole pipeline.
|
| 10 |
+
SEED = 1729
|
lighthouse/features.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic per-candidate feature extraction.
|
| 2 |
+
|
| 3 |
+
These are the building blocks of the five scoring components that do NOT require
|
| 4 |
+
embeddings (role-taxonomy coherence, career evidence, experience fit, skill
|
| 5 |
+
trust). `semantic_fit` is computed in `scoring.py` from precomputed embeddings.
|
| 6 |
+
|
| 7 |
+
Every function is pure and deterministic. All text matching is lowercase
|
| 8 |
+
substring matching against term lists in the JD rubric, so the logic is fully
|
| 9 |
+
explainable and traceable back to the JD.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
from datetime import date
|
| 15 |
+
from typing import Dict, List, Optional
|
| 16 |
+
|
| 17 |
+
from . import loader
|
| 18 |
+
|
| 19 |
+
PROFICIENCY_WEIGHT = {"beginner": 0.25, "intermediate": 0.5, "advanced": 0.8, "expert": 1.0}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# text helpers
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
def _norm(s: str) -> str:
|
| 27 |
+
return (s or "").lower()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def count_hits(text: str, terms: List[str]) -> int:
|
| 31 |
+
t = _norm(text)
|
| 32 |
+
return sum(1 for term in terms if term in t)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def any_hit(text: str, terms: List[str]) -> bool:
|
| 36 |
+
t = _norm(text)
|
| 37 |
+
return any(term in t for term in terms)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# title taxonomy
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
def classify_title(title: str, rubric: dict) -> str:
|
| 45 |
+
"""Return 'strong' | 'positive' | 'negative' | 'neutral' for a job title."""
|
| 46 |
+
t = _norm(title)
|
| 47 |
+
if not t:
|
| 48 |
+
return "neutral"
|
| 49 |
+
tax = rubric["role_taxonomy"]
|
| 50 |
+
if any(term in t for term in tax["strong_positive_title_terms"]):
|
| 51 |
+
return "strong"
|
| 52 |
+
if any(term in t for term in tax["negative_title_terms"]):
|
| 53 |
+
return "negative"
|
| 54 |
+
if any(term in t for term in tax["positive_title_terms"]):
|
| 55 |
+
return "positive"
|
| 56 |
+
return "neutral"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
_TITLE_SCORE = {"strong": 1.0, "positive": 0.7, "neutral": 0.4, "negative": 0.05}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def role_coherence_taxonomy(raw: dict, rubric: dict) -> float:
|
| 63 |
+
"""Coherence of titles with an AI/ML/IR/SWE-ranking trajectory, in [0,1].
|
| 64 |
+
|
| 65 |
+
Blends the current title (weighted heavily) with the fraction of historical
|
| 66 |
+
roles that are positive. This is the taxonomy half of `role_coherence`;
|
| 67 |
+
`scoring.py` blends it with semantic fit.
|
| 68 |
+
"""
|
| 69 |
+
p = loader.get_profile(raw)
|
| 70 |
+
cur = classify_title(loader._s(p, "current_title"), rubric)
|
| 71 |
+
cur_score = _TITLE_SCORE[cur]
|
| 72 |
+
|
| 73 |
+
career = loader.get_career(raw)
|
| 74 |
+
if career:
|
| 75 |
+
hist_scores = [_TITLE_SCORE[classify_title(h["title"], rubric)] for h in career]
|
| 76 |
+
hist = sum(hist_scores) / len(hist_scores)
|
| 77 |
+
else:
|
| 78 |
+
hist = cur_score
|
| 79 |
+
return round(0.6 * cur_score + 0.4 * hist, 4)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# services / product company detection
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
def is_services_company(name: str, rubric: dict) -> bool:
|
| 87 |
+
n = _norm(name)
|
| 88 |
+
return any(c in n for c in rubric["hard_negatives"][0]["companies"])
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def services_fraction(raw: dict, rubric: dict) -> float:
|
| 92 |
+
career = loader.get_career(raw)
|
| 93 |
+
if not career:
|
| 94 |
+
return 0.0
|
| 95 |
+
s = sum(1 for h in career if is_services_company(h["company"], rubric))
|
| 96 |
+
return s / len(career)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# career evidence
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
def career_evidence(raw: dict, rubric: dict) -> float:
|
| 104 |
+
"""Did they actually BUILD ranking/search/recsys/retrieval at product cos? [0,1]
|
| 105 |
+
|
| 106 |
+
Per role: term density over the description x a product-vs-services weight.
|
| 107 |
+
Aggregate emphasises the single best role (JD: 'shipped at least one
|
| 108 |
+
end-to-end ranking/search/recommendation system'), with a mean term so a
|
| 109 |
+
consistently-strong career also benefits.
|
| 110 |
+
"""
|
| 111 |
+
career = loader.get_career(raw)
|
| 112 |
+
if not career:
|
| 113 |
+
return 0.0
|
| 114 |
+
terms = rubric["career_evidence_terms"]
|
| 115 |
+
role_ev = []
|
| 116 |
+
for h in career:
|
| 117 |
+
hits = count_hits(h["description"] + " " + h["title"], terms)
|
| 118 |
+
density = min(hits / 4.0, 1.0)
|
| 119 |
+
if is_services_company(h["company"], rubric):
|
| 120 |
+
product_w = 0.35
|
| 121 |
+
elif h["company"]:
|
| 122 |
+
product_w = 1.0
|
| 123 |
+
else:
|
| 124 |
+
product_w = 0.85
|
| 125 |
+
role_ev.append(density * product_w)
|
| 126 |
+
return round(0.65 * max(role_ev) + 0.35 * (sum(role_ev) / len(role_ev)), 4)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# experience fit (soft curve)
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def experience_fit(raw: dict, rubric: dict) -> float:
|
| 134 |
+
"""Soft curve peaking inside ideal [6,8], in-band [5,9], gentle taper. [0,1]"""
|
| 135 |
+
yoe = loader._f(loader.get_profile(raw), "years_of_experience")
|
| 136 |
+
e = rubric["experience"]
|
| 137 |
+
lo, hi = e["ideal_min"], e["ideal_max"]
|
| 138 |
+
blo, bhi = e["band_min"], e["band_max"]
|
| 139 |
+
if lo <= yoe <= hi:
|
| 140 |
+
return 1.0
|
| 141 |
+
if blo <= yoe < lo:
|
| 142 |
+
return round(0.85 + 0.15 * (yoe - blo) / (lo - blo), 4)
|
| 143 |
+
if hi < yoe <= bhi:
|
| 144 |
+
return round(0.85 + 0.15 * (bhi - yoe) / (bhi - hi), 4)
|
| 145 |
+
# outside band: decay (don't zero — strong signals can rescue)
|
| 146 |
+
dist = (blo - yoe) if yoe < blo else (yoe - bhi)
|
| 147 |
+
return round(max(0.15, 0.7 * math.exp(-dist / 3.0)), 4)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
# skill trust
|
| 152 |
+
# ---------------------------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
def trust_skills(raw: dict, rubric: dict) -> float:
|
| 155 |
+
"""Skills weighted by proficiency x duration x endorsements x assessment,
|
| 156 |
+
counting only JD-relevant skills. Saturating. [0,1]
|
| 157 |
+
|
| 158 |
+
This is what makes keyword-stuffing structurally worthless: an 'expert'
|
| 159 |
+
skill with 0 months, no endorsements and a low Redrob assessment contributes
|
| 160 |
+
almost nothing. Missing assessment is NEUTRAL (76% of pool has none).
|
| 161 |
+
"""
|
| 162 |
+
skills = loader.get_skills(raw)
|
| 163 |
+
if not skills:
|
| 164 |
+
return 0.0
|
| 165 |
+
sig = loader.get_signals(raw)
|
| 166 |
+
assess = sig.get("skill_assessment_scores") or {}
|
| 167 |
+
# build a case-insensitive assessment lookup
|
| 168 |
+
assess_lc = {str(k).lower(): v for k, v in assess.items() if isinstance(v, (int, float))}
|
| 169 |
+
|
| 170 |
+
relevant = set(rubric["jd_relevant_skills"])
|
| 171 |
+
off = set(rubric["off_target_skills"])
|
| 172 |
+
|
| 173 |
+
trust_sum = 0.0
|
| 174 |
+
for sk in skills:
|
| 175 |
+
name = _norm(sk["name"])
|
| 176 |
+
if name in relevant:
|
| 177 |
+
rel_w = 1.0
|
| 178 |
+
elif name in off:
|
| 179 |
+
rel_w = 0.15
|
| 180 |
+
else:
|
| 181 |
+
rel_w = 0.4
|
| 182 |
+
if rel_w < 0.5:
|
| 183 |
+
continue # only JD-relevant skills build trust
|
| 184 |
+
|
| 185 |
+
prof = PROFICIENCY_WEIGHT.get(sk["proficiency"], 0.4)
|
| 186 |
+
dur_f = min(sk["duration_months"] / 24.0, 1.0)
|
| 187 |
+
dur_w = 0.3 + 0.7 * dur_f
|
| 188 |
+
end_f = min(sk["endorsements"] / 20.0, 1.0)
|
| 189 |
+
end_w = 0.7 + 0.3 * end_f
|
| 190 |
+
if name in assess_lc:
|
| 191 |
+
assess_w = assess_lc[name] / 100.0
|
| 192 |
+
else:
|
| 193 |
+
assess_w = 0.65 # neutral when no assessment taken
|
| 194 |
+
|
| 195 |
+
trust_sum += prof * dur_w * end_w * assess_w * rel_w
|
| 196 |
+
|
| 197 |
+
# saturate: ~3 well-backed relevant skills -> ~0.78
|
| 198 |
+
return round(1.0 - math.exp(-trust_sum / 2.0), 4)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
# tenure stats (for title-chaser gate + reasoning)
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
def tenure_stats(raw: dict) -> Dict[str, float]:
|
| 206 |
+
career = loader.get_career(raw)
|
| 207 |
+
completed = [h["duration_months"] for h in career if not h["is_current"] and h["duration_months"] > 0]
|
| 208 |
+
n_roles = len(career)
|
| 209 |
+
avg_tenure = (sum(completed) / len(completed)) if completed else 0.0
|
| 210 |
+
return {"n_roles": n_roles, "avg_tenure_months": round(avg_tenure, 1)}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
# bundle: all non-embedding features for one candidate
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
|
| 217 |
+
def extract_features(raw: dict, rubric: dict) -> dict:
|
| 218 |
+
"""All deterministic (non-embedding) features for one candidate."""
|
| 219 |
+
p = loader.get_profile(raw)
|
| 220 |
+
feats = {
|
| 221 |
+
"candidate_id": loader.candidate_id(raw),
|
| 222 |
+
"yoe": loader._f(p, "years_of_experience"),
|
| 223 |
+
"current_title": loader._s(p, "current_title"),
|
| 224 |
+
"country": loader._s(p, "country"),
|
| 225 |
+
"role_coherence_tax": role_coherence_taxonomy(raw, rubric),
|
| 226 |
+
"career_evidence": career_evidence(raw, rubric),
|
| 227 |
+
"experience_fit": experience_fit(raw, rubric),
|
| 228 |
+
"trust_skills": trust_skills(raw, rubric),
|
| 229 |
+
"services_fraction": services_fraction(raw, rubric),
|
| 230 |
+
}
|
| 231 |
+
feats.update(tenure_stats(raw))
|
| 232 |
+
return feats
|
lighthouse/gates.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hard-negative gates — multiplicative penalties derived from the JD rubric.
|
| 2 |
+
|
| 3 |
+
Each gate encodes one disqualifier the JD *explicitly* names. Gates are
|
| 4 |
+
multiplicative (not hard zeros — that is reserved for honeypots), so a strong
|
| 5 |
+
candidate with one soft concern is dampened, not annihilated, and the reasoning
|
| 6 |
+
generator can name the concern honestly.
|
| 7 |
+
|
| 8 |
+
Every gate returns at most one (key, multiplier, reason). `apply_gates` returns
|
| 9 |
+
the product of multipliers and the list of fired reasons.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from datetime import date
|
| 14 |
+
from typing import List, Tuple
|
| 15 |
+
|
| 16 |
+
from . import features, loader
|
| 17 |
+
|
| 18 |
+
_MIN_DATE = date(1900, 1, 1)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _career_text(raw: dict) -> str:
|
| 22 |
+
parts = [loader._s(loader.get_profile(raw), "summary")]
|
| 23 |
+
for h in loader.get_career(raw):
|
| 24 |
+
parts.append(h["title"])
|
| 25 |
+
parts.append(h["description"])
|
| 26 |
+
parts += [s["name"] for s in loader.get_skills(raw)]
|
| 27 |
+
return " ".join(parts).lower()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _gate(rubric: dict, key: str) -> dict:
|
| 31 |
+
return next(g for g in rubric["hard_negatives"] if g["key"] == key)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def gate_services_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 35 |
+
g = _gate(rubric, "services_only")
|
| 36 |
+
if features.services_fraction(raw, rubric) >= 0.999 and loader.get_career(raw):
|
| 37 |
+
cur = loader._s(loader.get_profile(raw), "current_company")
|
| 38 |
+
return g["penalty"], f"entire career at services/consulting firms (e.g. {cur})"
|
| 39 |
+
return 1.0, ""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def gate_location_visa(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 43 |
+
g = _gate(rubric, "location_visa")
|
| 44 |
+
p = loader.get_profile(raw)
|
| 45 |
+
sig = loader.get_signals(raw)
|
| 46 |
+
country = loader._s(p, "country").strip().lower()
|
| 47 |
+
if country and country != "india":
|
| 48 |
+
relocate = bool(sig.get("willing_to_relocate"))
|
| 49 |
+
loc = loader._s(p, "location") or country.title()
|
| 50 |
+
if not relocate:
|
| 51 |
+
return g["penalty"], f"based in {loc} ({country.title()}) and not willing to relocate; no visa sponsorship"
|
| 52 |
+
return g["soft_penalty"], f"based outside India ({loc}); relocation willing but visa/logistics risk"
|
| 53 |
+
return 1.0, ""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def gate_research_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 57 |
+
g = _gate(rubric, "research_only")
|
| 58 |
+
research = features.count_hits(text, g["positive_terms"])
|
| 59 |
+
production = features.count_hits(text, g["production_terms"])
|
| 60 |
+
if research >= 2 and production == 0:
|
| 61 |
+
return g["penalty"], "research-heavy profile with no evident production deployment"
|
| 62 |
+
return 1.0, ""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def gate_cv_speech_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 66 |
+
g = _gate(rubric, "cv_speech_only")
|
| 67 |
+
domain = features.count_hits(text, g["domain_terms"])
|
| 68 |
+
nlp_ir = features.count_hits(text, g["nlp_ir_terms"])
|
| 69 |
+
if domain >= 2 and nlp_ir == 0:
|
| 70 |
+
return g["penalty"], "primary expertise in computer vision/speech/robotics with no NLP/IR signal"
|
| 71 |
+
return 1.0, ""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def gate_langchain_only_recent(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 75 |
+
g = _gate(rubric, "langchain_only_recent")
|
| 76 |
+
wrapper = features.count_hits(text, g["wrapper_terms"])
|
| 77 |
+
depth = features.count_hits(text, g["depth_terms"])
|
| 78 |
+
if wrapper >= 2 and depth == 0:
|
| 79 |
+
return g["penalty"], "AI experience appears limited to recent LLM-wrapper tooling without classical ML/retrieval depth"
|
| 80 |
+
return 1.0, ""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _seniority_level(title: str) -> int:
|
| 84 |
+
t = title.lower()
|
| 85 |
+
if any(k in t for k in ("principal", "staff", "director", "vp", "head of", "distinguished")):
|
| 86 |
+
return 3
|
| 87 |
+
if "lead" in t or "manager" in t:
|
| 88 |
+
return 2
|
| 89 |
+
if "senior" in t or "sr." in t or "sr " in t:
|
| 90 |
+
return 1
|
| 91 |
+
return 0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def gate_title_chaser(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 95 |
+
"""Fire only on genuine title-chasing: short tenures AND an escalating
|
| 96 |
+
seniority ladder. Lateral moves at short tenure (common for early-career ML
|
| 97 |
+
engineers, e.g. RecSys -> Search -> NLP) are NOT title-chasing.
|
| 98 |
+
"""
|
| 99 |
+
g = _gate(rubric, "title_chaser")
|
| 100 |
+
stats = features.tenure_stats(raw)
|
| 101 |
+
if not (stats["n_roles"] >= g["min_roles"] and 0 < stats["avg_tenure_months"] < g["max_avg_tenure_months"]):
|
| 102 |
+
return 1.0, ""
|
| 103 |
+
# titles in chronological (oldest-first) order
|
| 104 |
+
career = sorted(loader.get_career(raw), key=lambda h: (h["start_date"] or _MIN_DATE))
|
| 105 |
+
levels = [_seniority_level(h["title"]) for h in career]
|
| 106 |
+
escalated = levels and (max(levels) - min(levels) >= 2) and levels[-1] >= levels[0] and levels[-1] >= 2
|
| 107 |
+
if escalated:
|
| 108 |
+
return g["penalty"], f"escalating titles while job-hopping every ~{stats['avg_tenure_months']:.0f} months across {stats['n_roles']} roles (title-chaser pattern)"
|
| 109 |
+
return 1.0, ""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def gate_non_technical_role(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
|
| 113 |
+
g = _gate(rubric, "non_technical_role")
|
| 114 |
+
p = loader.get_profile(raw)
|
| 115 |
+
cur_class = features.classify_title(loader._s(p, "current_title"), rubric)
|
| 116 |
+
if cur_class == "negative":
|
| 117 |
+
# whole trajectory non-technical? (no strong AI role anywhere)
|
| 118 |
+
career = loader.get_career(raw)
|
| 119 |
+
has_strong = any(features.classify_title(h["title"], rubric) == "strong" for h in career)
|
| 120 |
+
if not has_strong:
|
| 121 |
+
return g["penalty"], f"current role '{loader._s(p,'current_title')}' is non-engineering with no AI/ML role in career history"
|
| 122 |
+
return 1.0, ""
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
GATES = [
|
| 126 |
+
gate_non_technical_role,
|
| 127 |
+
gate_services_only,
|
| 128 |
+
gate_location_visa,
|
| 129 |
+
gate_research_only,
|
| 130 |
+
gate_cv_speech_only,
|
| 131 |
+
gate_langchain_only_recent,
|
| 132 |
+
gate_title_chaser,
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def apply_gates(raw: dict, rubric: dict) -> Tuple[float, List[str]]:
|
| 137 |
+
"""Return (combined_multiplier, [fired reasons])."""
|
| 138 |
+
text = _career_text(raw)
|
| 139 |
+
mult = 1.0
|
| 140 |
+
reasons: List[str] = []
|
| 141 |
+
for fn in GATES:
|
| 142 |
+
m, reason = fn(raw, rubric, text)
|
| 143 |
+
if m < 1.0:
|
| 144 |
+
mult *= m
|
| 145 |
+
reasons.append(reason)
|
| 146 |
+
return mult, reasons
|
lighthouse/honeypot.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Honeypot / anomaly detection — explainable, rule-based.
|
| 2 |
+
|
| 3 |
+
The dataset seeds ~80 honeypots: "subtly impossible" profiles (tenure longer
|
| 4 |
+
than the company has existed; 'expert' in many skills with 0 months used; total
|
| 5 |
+
skill-months far exceeding career length; dates that don't add up). The spec
|
| 6 |
+
forces them to relevance tier 0 and disqualifies submissions with >10% of them
|
| 7 |
+
in the top 100. `rank.py` zeroes any candidate flagged here.
|
| 8 |
+
|
| 9 |
+
Design notes grounded in EDA over the real 100K:
|
| 10 |
+
* The naive "sum(skill_months) > years_of_experience*12" check fires on 63% of
|
| 11 |
+
the pool (skills are used concurrently), so it is USELESS as written. We only
|
| 12 |
+
flag a *single* skill whose duration exceeds the whole career — that is
|
| 13 |
+
genuinely impossible, not just overlapping.
|
| 14 |
+
* Every rule returns a human-readable reason so flags are auditable and feed
|
| 15 |
+
the reasoning generator.
|
| 16 |
+
|
| 17 |
+
Reference "now" is the JD reference date (2026-06-06).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from datetime import date
|
| 22 |
+
from typing import List, Tuple
|
| 23 |
+
|
| 24 |
+
from . import loader
|
| 25 |
+
|
| 26 |
+
REFERENCE_DATE = date(2026, 6, 6)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _months_between(d0: date, d1: date) -> int:
|
| 30 |
+
return (d1.year - d0.year) * 12 + (d1.month - d0.month)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detect(raw: dict) -> Tuple[bool, List[str]]:
|
| 34 |
+
"""Return (is_honeypot, reasons). Any single hard-impossible rule flags."""
|
| 35 |
+
reasons: List[str] = []
|
| 36 |
+
p = loader.get_profile(raw)
|
| 37 |
+
yoe = loader._f(p, "years_of_experience")
|
| 38 |
+
career = loader.get_career(raw)
|
| 39 |
+
skills = loader.get_skills(raw)
|
| 40 |
+
edu = loader.get_education(raw)
|
| 41 |
+
|
| 42 |
+
# --- 1. claimed expertise with zero usage ---
|
| 43 |
+
expert_zero = [s["name"] for s in skills
|
| 44 |
+
if s["proficiency"] in ("advanced", "expert") and s["duration_months"] == 0]
|
| 45 |
+
if len(expert_zero) >= 3:
|
| 46 |
+
reasons.append(
|
| 47 |
+
f"{len(expert_zero)} skills claimed advanced/expert with 0 months used "
|
| 48 |
+
f"(e.g. {', '.join(expert_zero[:3])})")
|
| 49 |
+
|
| 50 |
+
# NOTE: a tempting rule — "a skill used more months than the whole career" —
|
| 51 |
+
# was REMOVED after EDA: skill durations routinely exceed current-job YOE
|
| 52 |
+
# (skills are learned before/outside a role), so that rule flagged 9% of the
|
| 53 |
+
# pool. Genuine impossibility lives in the date math and expertise claims,
|
| 54 |
+
# not in skill-vs-YOE.
|
| 55 |
+
|
| 56 |
+
# --- 2 & 3. per-role date integrity ---
|
| 57 |
+
total_role_months = 0
|
| 58 |
+
for h in career:
|
| 59 |
+
sd, ed = h["start_date"], h["end_date"]
|
| 60 |
+
dm = h["duration_months"]
|
| 61 |
+
total_role_months += dm
|
| 62 |
+
if sd and sd > REFERENCE_DATE:
|
| 63 |
+
reasons.append(f"role at {h['company']} starts in the future ({sd.isoformat()})")
|
| 64 |
+
if sd and ed and ed < sd:
|
| 65 |
+
reasons.append(f"role at {h['company']} ends before it starts")
|
| 66 |
+
if sd and ed:
|
| 67 |
+
span = _months_between(sd, ed)
|
| 68 |
+
if span >= 0 and abs(span - dm) > 9:
|
| 69 |
+
reasons.append(
|
| 70 |
+
f"role at {h['company']}: stated {dm}mo vs {span}mo implied by dates")
|
| 71 |
+
|
| 72 |
+
# --- 5. total tenure overflows the career length ---
|
| 73 |
+
if yoe > 0 and total_role_months > yoe * 12 * 1.6 + 18:
|
| 74 |
+
reasons.append(
|
| 75 |
+
f"career roles sum to {total_role_months}mo, impossible for {yoe:.1f} yrs experience")
|
| 76 |
+
|
| 77 |
+
# --- 6. tenure longer than a young company could exist (proxy) ---
|
| 78 |
+
# A single role of >15 yrs is implausible in this pool's company ages.
|
| 79 |
+
for h in career:
|
| 80 |
+
if h["duration_months"] > 200:
|
| 81 |
+
reasons.append(f"implausible {h['duration_months']}mo tenure at {h['company']}")
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
# --- 7. education dates invalid ---
|
| 85 |
+
for e in edu:
|
| 86 |
+
if e["start_year"] and e["end_year"] and e["end_year"] < e["start_year"]:
|
| 87 |
+
reasons.append(
|
| 88 |
+
f"education '{e['degree']}' ends ({e['end_year']}) before it starts ({e['start_year']})")
|
| 89 |
+
|
| 90 |
+
return (len(reasons) > 0, reasons)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def is_honeypot(raw: dict) -> bool:
|
| 94 |
+
return detect(raw)[0]
|
lighthouse/loader.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Schema-tolerant loading of Redrob candidate records.
|
| 2 |
+
|
| 3 |
+
The challenge file is a 100K-line JSONL (~487 MB). We stream it line-by-line so
|
| 4 |
+
we never hold more than one raw record in memory at a time during parsing.
|
| 5 |
+
|
| 6 |
+
Every accessor here is defensive: optional fields may be missing, lists may be
|
| 7 |
+
empty, and the dataset uses sentinels (`github_activity_score == -1`,
|
| 8 |
+
`offer_acceptance_rate == -1`, empty `skill_assessment_scores`). Nothing in this
|
| 9 |
+
module should ever raise on a well-formed-but-sparse profile.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
from datetime import date, datetime
|
| 15 |
+
from typing import Any, Dict, Iterator, List, Optional
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Low-level safe accessors
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
def _s(d: Optional[dict], key: str, default: str = "") -> str:
|
| 22 |
+
if not isinstance(d, dict):
|
| 23 |
+
return default
|
| 24 |
+
v = d.get(key, default)
|
| 25 |
+
return v if isinstance(v, str) else (default if v is None else str(v))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _f(d: Optional[dict], key: str, default: float = 0.0) -> float:
|
| 29 |
+
if not isinstance(d, dict):
|
| 30 |
+
return default
|
| 31 |
+
v = d.get(key, default)
|
| 32 |
+
try:
|
| 33 |
+
return float(v)
|
| 34 |
+
except (TypeError, ValueError):
|
| 35 |
+
return default
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _i(d: Optional[dict], key: str, default: int = 0) -> int:
|
| 39 |
+
if not isinstance(d, dict):
|
| 40 |
+
return default
|
| 41 |
+
v = d.get(key, default)
|
| 42 |
+
try:
|
| 43 |
+
return int(v)
|
| 44 |
+
except (TypeError, ValueError):
|
| 45 |
+
return default
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _b(d: Optional[dict], key: str, default: bool = False) -> bool:
|
| 49 |
+
if not isinstance(d, dict):
|
| 50 |
+
return default
|
| 51 |
+
v = d.get(key, default)
|
| 52 |
+
return bool(v) if isinstance(v, bool) else default
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def parse_date(s: Any) -> Optional[date]:
|
| 56 |
+
"""Parse an ISO date string; return None for missing/invalid/null."""
|
| 57 |
+
if not s or not isinstance(s, str):
|
| 58 |
+
return None
|
| 59 |
+
try:
|
| 60 |
+
return datetime.strptime(s[:10], "%Y-%m-%d").date()
|
| 61 |
+
except (ValueError, TypeError):
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Streaming reader
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def iter_raw(path: str) -> Iterator[dict]:
|
| 70 |
+
"""Yield raw candidate dicts from a JSONL file, one line at a time."""
|
| 71 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 72 |
+
for line in f:
|
| 73 |
+
line = line.strip()
|
| 74 |
+
if not line:
|
| 75 |
+
continue
|
| 76 |
+
try:
|
| 77 |
+
yield json.loads(line)
|
| 78 |
+
except json.JSONDecodeError:
|
| 79 |
+
# Skip malformed lines rather than aborting the whole run.
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_all(path: str) -> List[dict]:
|
| 84 |
+
"""Load every raw candidate record into a list."""
|
| 85 |
+
return list(iter_raw(path))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# Normalisation
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
def get_skills(raw: dict) -> List[dict]:
|
| 93 |
+
skills = raw.get("skills") or []
|
| 94 |
+
out = []
|
| 95 |
+
for sk in skills:
|
| 96 |
+
if not isinstance(sk, dict):
|
| 97 |
+
continue
|
| 98 |
+
out.append({
|
| 99 |
+
"name": _s(sk, "name"),
|
| 100 |
+
"proficiency": _s(sk, "proficiency", "beginner").lower(),
|
| 101 |
+
"endorsements": _i(sk, "endorsements"),
|
| 102 |
+
"duration_months": _i(sk, "duration_months"),
|
| 103 |
+
})
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def get_career(raw: dict) -> List[dict]:
|
| 108 |
+
hist = raw.get("career_history") or []
|
| 109 |
+
out = []
|
| 110 |
+
for h in hist:
|
| 111 |
+
if not isinstance(h, dict):
|
| 112 |
+
continue
|
| 113 |
+
out.append({
|
| 114 |
+
"company": _s(h, "company"),
|
| 115 |
+
"title": _s(h, "title"),
|
| 116 |
+
"start_date": parse_date(h.get("start_date")),
|
| 117 |
+
"end_date": parse_date(h.get("end_date")),
|
| 118 |
+
"duration_months": _i(h, "duration_months"),
|
| 119 |
+
"is_current": _b(h, "is_current"),
|
| 120 |
+
"industry": _s(h, "industry"),
|
| 121 |
+
"company_size": _s(h, "company_size"),
|
| 122 |
+
"description": _s(h, "description"),
|
| 123 |
+
})
|
| 124 |
+
return out
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def get_education(raw: dict) -> List[dict]:
|
| 128 |
+
edu = raw.get("education") or []
|
| 129 |
+
out = []
|
| 130 |
+
for e in edu:
|
| 131 |
+
if not isinstance(e, dict):
|
| 132 |
+
continue
|
| 133 |
+
out.append({
|
| 134 |
+
"institution": _s(e, "institution"),
|
| 135 |
+
"degree": _s(e, "degree"),
|
| 136 |
+
"field_of_study": _s(e, "field_of_study"),
|
| 137 |
+
"start_year": _i(e, "start_year"),
|
| 138 |
+
"end_year": _i(e, "end_year"),
|
| 139 |
+
"tier": _s(e, "tier", "unknown"),
|
| 140 |
+
})
|
| 141 |
+
return out
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def get_signals(raw: dict) -> dict:
|
| 145 |
+
"""Return the redrob_signals object (raw dict, with safe fallback)."""
|
| 146 |
+
sig = raw.get("redrob_signals")
|
| 147 |
+
return sig if isinstance(sig, dict) else {}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_profile(raw: dict) -> dict:
|
| 151 |
+
p = raw.get("profile")
|
| 152 |
+
return p if isinstance(p, dict) else {}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def candidate_id(raw: dict) -> str:
|
| 156 |
+
return _s(raw, "candidate_id")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
# Canonical text blob (used by both embeddings and BM25)
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
def build_text_blob(raw: dict) -> str:
|
| 164 |
+
"""Build one clean text blob per candidate for semantic + lexical matching.
|
| 165 |
+
|
| 166 |
+
Order matters for readability but not for embeddings: headline + summary
|
| 167 |
+
first (densest signal), then each role's title/description, then skill names.
|
| 168 |
+
We deliberately repeat titles/descriptions verbatim — they are the strongest
|
| 169 |
+
evidence of what the person actually *did*.
|
| 170 |
+
"""
|
| 171 |
+
p = get_profile(raw)
|
| 172 |
+
parts: List[str] = []
|
| 173 |
+
|
| 174 |
+
headline = _s(p, "headline")
|
| 175 |
+
if headline:
|
| 176 |
+
parts.append(headline)
|
| 177 |
+
|
| 178 |
+
summary = _s(p, "summary")
|
| 179 |
+
if summary:
|
| 180 |
+
parts.append(summary)
|
| 181 |
+
|
| 182 |
+
cur_title = _s(p, "current_title")
|
| 183 |
+
cur_co = _s(p, "current_company")
|
| 184 |
+
if cur_title:
|
| 185 |
+
parts.append(f"{cur_title} at {cur_co}".strip(" at"))
|
| 186 |
+
|
| 187 |
+
for h in get_career(raw):
|
| 188 |
+
seg = f"{h['title']} at {h['company']} ({h['industry']}). {h['description']}".strip()
|
| 189 |
+
if seg:
|
| 190 |
+
parts.append(seg)
|
| 191 |
+
|
| 192 |
+
skill_names = [sk["name"] for sk in get_skills(raw) if sk["name"]]
|
| 193 |
+
if skill_names:
|
| 194 |
+
parts.append("Skills: " + ", ".join(skill_names))
|
| 195 |
+
|
| 196 |
+
return "\n".join(parts).strip()
|
lighthouse/metrics.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ranking metrics: NDCG@k, MAP, P@k — matching the challenge spec.
|
| 2 |
+
|
| 3 |
+
The composite the organizers use:
|
| 4 |
+
composite = 0.50*NDCG@10 + 0.30*NDCG@50 + 0.15*MAP + 0.05*P@10
|
| 5 |
+
|
| 6 |
+
Relevance tiers are 0-5. Conventions used here (documented in eval/results.md):
|
| 7 |
+
* NDCG uses exponential gain (2^rel - 1) / log2(rank+1) — the common form.
|
| 8 |
+
* "relevant" for P@k and MAP means tier >= 3 (the spec: 'P@10 counts tier-3+
|
| 9 |
+
as relevant'); we apply the same threshold to MAP for consistency.
|
| 10 |
+
All functions take `rels`, the true relevance tiers in the system's ranked order.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import math
|
| 15 |
+
from typing import List
|
| 16 |
+
|
| 17 |
+
RELEVANT_TIER = 3
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def dcg_at_k(rels: List[float], k: int) -> float:
|
| 21 |
+
return sum((2 ** r - 1) / math.log2(i + 2) for i, r in enumerate(rels[:k]))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def ndcg_at_k(rels: List[float], k: int) -> float:
|
| 25 |
+
ideal = sorted(rels, reverse=True)
|
| 26 |
+
idcg = dcg_at_k(ideal, k)
|
| 27 |
+
if idcg == 0:
|
| 28 |
+
return 0.0
|
| 29 |
+
return dcg_at_k(rels, k) / idcg
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def precision_at_k(rels: List[float], k: int, threshold: int = RELEVANT_TIER) -> float:
|
| 33 |
+
if k == 0:
|
| 34 |
+
return 0.0
|
| 35 |
+
topk = rels[:k]
|
| 36 |
+
return sum(1 for r in topk if r >= threshold) / k
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def average_precision(rels: List[float], threshold: int = RELEVANT_TIER) -> float:
|
| 40 |
+
"""AP over the ranked list; R = total relevant in the list."""
|
| 41 |
+
n_rel = sum(1 for r in rels if r >= threshold)
|
| 42 |
+
if n_rel == 0:
|
| 43 |
+
return 0.0
|
| 44 |
+
hits = 0
|
| 45 |
+
score = 0.0
|
| 46 |
+
for i, r in enumerate(rels, start=1):
|
| 47 |
+
if r >= threshold:
|
| 48 |
+
hits += 1
|
| 49 |
+
score += hits / i
|
| 50 |
+
return score / n_rel
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def evaluate_ranking(rels: List[float]) -> dict:
|
| 54 |
+
"""All metrics + the spec composite for one ranked list of true tiers."""
|
| 55 |
+
ndcg10 = ndcg_at_k(rels, 10)
|
| 56 |
+
ndcg50 = ndcg_at_k(rels, 50)
|
| 57 |
+
mapv = average_precision(rels)
|
| 58 |
+
p10 = precision_at_k(rels, 10)
|
| 59 |
+
composite = 0.50 * ndcg10 + 0.30 * ndcg50 + 0.15 * mapv + 0.05 * p10
|
| 60 |
+
return {
|
| 61 |
+
"NDCG@10": round(ndcg10, 4), "NDCG@50": round(ndcg50, 4),
|
| 62 |
+
"MAP": round(mapv, 4), "P@10": round(p10, 4),
|
| 63 |
+
"P@5": round(precision_at_k(rels, 5), 4),
|
| 64 |
+
"composite": round(composite, 4),
|
| 65 |
+
}
|
lighthouse/ranker.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rank-time engine: load artifacts, score every candidate, sort, take top-N.
|
| 2 |
+
|
| 3 |
+
Pure numpy/pandas/Python. No network, no model inference over the full pool
|
| 4 |
+
(embeddings are precomputed). The only place a model may load is the small-sample
|
| 5 |
+
fallback (`_embeddings_for`) used by the HuggingFace app when it is handed
|
| 6 |
+
candidates that were never precomputed — never on the 100K official path.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
from typing import Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from . import loader, scoring
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# artifact loading
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
def load_artifacts(art_dir: str) -> dict:
|
| 24 |
+
rubric = json.load(open(os.path.join(art_dir, "jd_rubric.json"), encoding="utf-8"))
|
| 25 |
+
ids = json.load(open(os.path.join(art_dir, "candidate_ids.json"), encoding="utf-8"))
|
| 26 |
+
cand_emb = np.load(os.path.join(art_dir, "cand_emb.npy"))
|
| 27 |
+
facet_emb = np.load(os.path.join(art_dir, "jd_facet_emb.npy"))
|
| 28 |
+
id_to_row = {cid: i for i, cid in enumerate(ids)}
|
| 29 |
+
return {"rubric": rubric, "ids": ids, "cand_emb": cand_emb,
|
| 30 |
+
"facet_emb": facet_emb, "id_to_row": id_to_row}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _embeddings_for(raws: List[dict], art: dict, model_name: Optional[str]) -> np.ndarray:
|
| 34 |
+
"""Return an embedding matrix aligned to `raws`, using precomputed vectors
|
| 35 |
+
where available and encoding any missing ones on the fly (small-sample only).
|
| 36 |
+
"""
|
| 37 |
+
cand_emb = art["cand_emb"]
|
| 38 |
+
dim = cand_emb.shape[1]
|
| 39 |
+
out = np.zeros((len(raws), dim), dtype=np.float32)
|
| 40 |
+
missing_idx, missing_blobs = [], []
|
| 41 |
+
for i, raw in enumerate(raws):
|
| 42 |
+
row = art["id_to_row"].get(loader.candidate_id(raw))
|
| 43 |
+
if row is not None:
|
| 44 |
+
out[i] = cand_emb[row].astype(np.float32)
|
| 45 |
+
else:
|
| 46 |
+
missing_idx.append(i)
|
| 47 |
+
missing_blobs.append(loader.build_text_blob(raw))
|
| 48 |
+
if missing_blobs:
|
| 49 |
+
# small-sample fallback (e.g. HF app) — load the small encoder lazily
|
| 50 |
+
from sentence_transformers import SentenceTransformer
|
| 51 |
+
meta_path = os.path.join("artifacts", "precompute_meta.json")
|
| 52 |
+
mn = model_name or (json.load(open(meta_path))["model"]
|
| 53 |
+
if os.path.exists(meta_path) else "BAAI/bge-small-en-v1.5")
|
| 54 |
+
model = SentenceTransformer(mn, device="cpu")
|
| 55 |
+
vecs = model.encode(missing_blobs, normalize_embeddings=True, convert_to_numpy=True)
|
| 56 |
+
for j, i in enumerate(missing_idx):
|
| 57 |
+
out[i] = vecs[j]
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# scoring all candidates
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def score_all(raws: List[dict], art: dict, drop: str = None,
|
| 66 |
+
model_name: Optional[str] = None, use_gates: bool = True,
|
| 67 |
+
use_honeypot: bool = True, use_behavior: bool = True) -> List[dict]:
|
| 68 |
+
rubric = art["rubric"]
|
| 69 |
+
emb = _embeddings_for(raws, art, model_name)
|
| 70 |
+
sem_raw = scoring.raw_semantic_fit(emb, art["facet_emb"])
|
| 71 |
+
sem_norm = scoring.normalize_semantic(sem_raw)
|
| 72 |
+
records = []
|
| 73 |
+
for raw, sf in zip(raws, sem_norm):
|
| 74 |
+
records.append(scoring.score_candidate(
|
| 75 |
+
raw, rubric, float(sf), drop=drop, use_gates=use_gates,
|
| 76 |
+
use_honeypot=use_honeypot, use_behavior=use_behavior))
|
| 77 |
+
return records
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# ranking + ordering (spec-compliant)
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def rank_records(records: List[dict], top: int = 100) -> List[dict]:
|
| 85 |
+
"""Sort by final_score desc, tie-break candidate_id ascending; take top-N.
|
| 86 |
+
|
| 87 |
+
Guarantees the validator's constraints: scores non-increasing by rank and
|
| 88 |
+
equal scores ordered by candidate_id ascending.
|
| 89 |
+
"""
|
| 90 |
+
ordered = sorted(records, key=lambda r: (-r["final_score"], r["candidate_id"]))
|
| 91 |
+
top_recs = ordered[:top]
|
| 92 |
+
for i, r in enumerate(top_recs):
|
| 93 |
+
r["rank"] = i + 1
|
| 94 |
+
return top_recs
|
lighthouse/reasoning.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic, fact-grounded reasoning generator (NO LLM at rank-time).
|
| 2 |
+
|
| 3 |
+
For each top-100 candidate we compose a 1-2 sentence justification that:
|
| 4 |
+
* cites only REAL field values (years, title, named skills, signal numbers),
|
| 5 |
+
* connects them to specific JD requirements,
|
| 6 |
+
* honestly names gaps (gate concerns, behavioral risks),
|
| 7 |
+
* varies by which factors dominate THIS candidate, and
|
| 8 |
+
* matches its rank band in tone (confident high, "filler" honesty low).
|
| 9 |
+
|
| 10 |
+
Grounding guarantee: every skill or employer surfaced is pulled directly from
|
| 11 |
+
the candidate's own `skills` / `career_history` / `profile`, never invented.
|
| 12 |
+
`grounded_terms()` exposes exactly what was used so tests can verify it.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from datetime import date
|
| 17 |
+
from typing import Dict, List, Tuple
|
| 18 |
+
|
| 19 |
+
from . import features, loader
|
| 20 |
+
|
| 21 |
+
REFERENCE_DATE = date(2026, 6, 6)
|
| 22 |
+
|
| 23 |
+
# representative "build" words; only surfaced if present in the candidate's text
|
| 24 |
+
_EVIDENCE_WORDS = [
|
| 25 |
+
("ranking", "ranking"), ("learning to rank", "learning-to-rank"),
|
| 26 |
+
("recommendation", "recommendation"), ("recommender", "recommendation"),
|
| 27 |
+
("retrieval", "retrieval"), ("search", "search"), ("embedding", "embeddings"),
|
| 28 |
+
("semantic search", "semantic search"), ("personalization", "personalization"),
|
| 29 |
+
("information retrieval", "information retrieval"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _fmt_yoe(y: float) -> str:
|
| 34 |
+
return f"{y:.1f}".rstrip("0").rstrip(".") + " yrs"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def extract_facts(raw: dict, rubric: dict, record: dict) -> dict:
|
| 38 |
+
"""Pull grounded facts used to compose reasoning."""
|
| 39 |
+
p = loader.get_profile(raw)
|
| 40 |
+
sig = loader.get_signals(raw)
|
| 41 |
+
skills = loader.get_skills(raw)
|
| 42 |
+
career = loader.get_career(raw)
|
| 43 |
+
|
| 44 |
+
# JD-relevant skills the candidate actually has, strongest first
|
| 45 |
+
relevant = set(rubric["jd_relevant_skills"])
|
| 46 |
+
rel_present = [s for s in skills if s["name"].lower() in relevant]
|
| 47 |
+
rel_present.sort(key=lambda s: (features.PROFICIENCY_WEIGHT.get(s["proficiency"], 0.4)
|
| 48 |
+
* min(s["duration_months"] / 24.0, 1.0)), reverse=True)
|
| 49 |
+
top_skills = [s["name"] for s in rel_present[:3]]
|
| 50 |
+
|
| 51 |
+
# product (non-services) employers actually in the career history
|
| 52 |
+
product_cos = []
|
| 53 |
+
for h in career:
|
| 54 |
+
if h["company"] and not features.is_services_company(h["company"], rubric):
|
| 55 |
+
if h["company"] not in product_cos:
|
| 56 |
+
product_cos.append(h["company"])
|
| 57 |
+
|
| 58 |
+
# which "build" word is genuinely present in their text
|
| 59 |
+
text = " ".join([loader._s(p, "summary")] + [h["description"] + " " + h["title"] for h in career]).lower()
|
| 60 |
+
evidence_word = None
|
| 61 |
+
for needle, label in _EVIDENCE_WORDS:
|
| 62 |
+
if needle in text:
|
| 63 |
+
evidence_word = label
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
la = loader.parse_date(sig.get("last_active_date"))
|
| 67 |
+
days_active = (REFERENCE_DATE - la).days if la else None
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
"yoe": loader._f(p, "years_of_experience"),
|
| 71 |
+
"title": loader._s(p, "current_title"),
|
| 72 |
+
"company": loader._s(p, "current_company"),
|
| 73 |
+
"country": loader._s(p, "country"),
|
| 74 |
+
"location": loader._s(p, "location"),
|
| 75 |
+
"top_skills": top_skills,
|
| 76 |
+
"product_cos": product_cos[:2],
|
| 77 |
+
"evidence_word": evidence_word,
|
| 78 |
+
"resp_rate": sig.get("recruiter_response_rate"),
|
| 79 |
+
"days_active": days_active,
|
| 80 |
+
"notice": sig.get("notice_period_days"),
|
| 81 |
+
"relocate": sig.get("willing_to_relocate"),
|
| 82 |
+
"n_relevant_skills": len(rel_present),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def grounded_terms(raw: dict, rubric: dict, record: dict) -> Dict[str, List[str]]:
|
| 87 |
+
"""The skills/companies the generator is allowed to mention (for tests)."""
|
| 88 |
+
f = extract_facts(raw, rubric, record)
|
| 89 |
+
return {"skills": f["top_skills"], "companies": f["product_cos"]}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _positive_clause(f: dict, comps: dict, band: str) -> str:
|
| 93 |
+
"""Lead clause built from the candidate's strongest grounded evidence."""
|
| 94 |
+
title = f["title"] or "Engineer"
|
| 95 |
+
yoe = _fmt_yoe(f["yoe"])
|
| 96 |
+
lead = f"{title} with {yoe}"
|
| 97 |
+
|
| 98 |
+
# choose the dominant evidence to feature
|
| 99 |
+
bits = []
|
| 100 |
+
if f["evidence_word"] and comps["career_evidence"] >= 0.45:
|
| 101 |
+
cos = f" at {', '.join(f['product_cos'])}" if f["product_cos"] else " at product companies"
|
| 102 |
+
bits.append(f"built {f['evidence_word']} systems{cos}")
|
| 103 |
+
elif f["product_cos"] and comps["role_coherence"] >= 0.6:
|
| 104 |
+
bits.append(f"applied-ML track record at {', '.join(f['product_cos'])}")
|
| 105 |
+
|
| 106 |
+
if f["top_skills"]:
|
| 107 |
+
if len(f["top_skills"]) == 1:
|
| 108 |
+
bits.append(f"hands-on with {f['top_skills'][0]}")
|
| 109 |
+
else:
|
| 110 |
+
bits.append(f"hands-on with {', '.join(f['top_skills'][:2])}")
|
| 111 |
+
|
| 112 |
+
if not bits:
|
| 113 |
+
# nothing strong to feature — keep it honest
|
| 114 |
+
if comps["semantic_fit"] >= 0.5:
|
| 115 |
+
bits.append("profile semantically adjacent to the retrieval/ranking mandate")
|
| 116 |
+
else:
|
| 117 |
+
bits.append("limited direct evidence of production retrieval/ranking work")
|
| 118 |
+
|
| 119 |
+
body = "; ".join(bits)
|
| 120 |
+
if band == "top":
|
| 121 |
+
return f"{lead} — {body}."
|
| 122 |
+
if band == "mid":
|
| 123 |
+
return f"{lead}; {body}."
|
| 124 |
+
# capitalize only the first letter (preserve proper-noun casing in company/skill names)
|
| 125 |
+
body_cap = body[0].upper() + body[1:] if body else body
|
| 126 |
+
return f"{lead}. {body_cap}."
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _concern_clause(f: dict, record: dict, band: str) -> str:
|
| 130 |
+
"""Honest concern/gap clause. Prefers the most material concern."""
|
| 131 |
+
concerns = list(record.get("gate_reasons", []))
|
| 132 |
+
concerns += [c for c in record.get("behavior_facts", [])
|
| 133 |
+
if any(k in c for k in ("low", "inactive", "notice", "last active"))]
|
| 134 |
+
|
| 135 |
+
if record.get("honeypot"):
|
| 136 |
+
return f" Flagged as anomalous: {record['honeypot_reasons'][0]}."
|
| 137 |
+
|
| 138 |
+
if concerns:
|
| 139 |
+
return " Concern: " + concerns[0] + "."
|
| 140 |
+
|
| 141 |
+
# no concern -> reinforce with a real positive behavioral signal
|
| 142 |
+
pos = []
|
| 143 |
+
if isinstance(f["resp_rate"], (int, float)) and f["resp_rate"] >= 0.5:
|
| 144 |
+
pos.append(f"{f['resp_rate']:.0%} recruiter response")
|
| 145 |
+
if f["days_active"] is not None and f["days_active"] <= 60:
|
| 146 |
+
pos.append(f"active {f['days_active']}d ago")
|
| 147 |
+
if f["country"].lower() == "india":
|
| 148 |
+
pos.append("India-based")
|
| 149 |
+
elif f["relocate"]:
|
| 150 |
+
pos.append("willing to relocate")
|
| 151 |
+
if pos:
|
| 152 |
+
s = ", ".join(pos)
|
| 153 |
+
return " " + s[0].upper() + s[1:] + "."
|
| 154 |
+
return ""
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def generate(raw: dict, rubric: dict, record: dict) -> str:
|
| 158 |
+
"""Compose the 1-2 sentence grounded reasoning for one ranked candidate."""
|
| 159 |
+
rank = record.get("rank", 999)
|
| 160 |
+
band = "top" if rank <= 10 else ("mid" if rank <= 50 else "low")
|
| 161 |
+
f = extract_facts(raw, rubric, record)
|
| 162 |
+
comps = record["components"]
|
| 163 |
+
|
| 164 |
+
text = _positive_clause(f, comps, band) + _concern_clause(f, record, band)
|
| 165 |
+
|
| 166 |
+
if band == "low" and not record.get("gate_reasons") and not record.get("honeypot"):
|
| 167 |
+
text += " Adjacent fit included near the cutoff."
|
| 168 |
+
|
| 169 |
+
return " ".join(text.split()).strip()
|
lighthouse/scoring.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Lighthouse scoring model — the core IP.
|
| 2 |
+
|
| 3 |
+
Five explainable components in [0,1] are combined into a base score, then
|
| 4 |
+
modified multiplicatively:
|
| 5 |
+
|
| 6 |
+
final = base_weighted_sum × Π(hard-negative gates) × behavioral_modifier
|
| 7 |
+
final = 0 if honeypot/anomaly detected
|
| 8 |
+
|
| 9 |
+
| component | what it measures | anti-trap role |
|
| 10 |
+
|-----------------|-------------------------------------------------------------|---------------------------|
|
| 11 |
+
| semantic_fit | cosine of candidate embedding vs JD facets | plain-language Tier-5s |
|
| 12 |
+
| role_coherence | is the title/trajectory actually AI/ML/IR/SWE-ranking? | decisive vs keyword-stuff |
|
| 13 |
+
| career_evidence | did they BUILD ranking/search/recsys at product companies? | rewards real builders |
|
| 14 |
+
| experience_fit | soft curve peaking 6-8 yrs, in-band 5-9 | matches the JD band |
|
| 15 |
+
| trust_skills | skills weighted by proficiency×duration×endorse×assessment | kills keyword stuffing |
|
| 16 |
+
|
| 17 |
+
`semantic_fit` is supplied pre-normalized (population percentile-scaled) by the
|
| 18 |
+
caller; everything else is computed here from the rubric + raw fields.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from datetime import date
|
| 23 |
+
from typing import Dict, List, Tuple
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from . import features, gates, honeypot, loader
|
| 28 |
+
|
| 29 |
+
REFERENCE_DATE = date(2026, 6, 6)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# semantic fit (embedding side)
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def raw_semantic_fit(cand_emb: np.ndarray, facet_emb: np.ndarray) -> np.ndarray:
|
| 37 |
+
"""Per-candidate raw semantic fit = 0.6*max + 0.4*mean facet cosine.
|
| 38 |
+
|
| 39 |
+
Both matrices are L2-normalized, so the dot product is cosine. Returns a
|
| 40 |
+
1-D array aligned to cand_emb rows. (max rewards a strong single-facet
|
| 41 |
+
match; mean rewards broad alignment.)
|
| 42 |
+
"""
|
| 43 |
+
cos = cand_emb.astype(np.float32) @ facet_emb.T # (N, F)
|
| 44 |
+
return 0.6 * cos.max(axis=1) + 0.4 * cos.mean(axis=1)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def normalize_semantic(raw: np.ndarray) -> np.ndarray:
|
| 48 |
+
"""Percentile-clip raw semantic fit into [0,1] across the population."""
|
| 49 |
+
if len(raw) == 0:
|
| 50 |
+
return raw
|
| 51 |
+
lo = np.percentile(raw, 5)
|
| 52 |
+
hi = np.percentile(raw, 95)
|
| 53 |
+
if hi - lo < 1e-6:
|
| 54 |
+
return np.clip((raw - raw.min()) / (raw.ptp() + 1e-6), 0, 1)
|
| 55 |
+
return np.clip((raw - lo) / (hi - lo), 0.0, 1.0)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# behavioral modifier (multiplicative, clamped)
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def behavioral_modifier(raw: dict, rubric: dict) -> Tuple[float, List[str]]:
|
| 63 |
+
"""Reward reachable/active candidates, penalize stale/unreachable. [floor,ceiling].
|
| 64 |
+
|
| 65 |
+
Sentinels (-1 github, -1 offer, empty assessments) are NEUTRAL — only
|
| 66 |
+
present signals move the modifier. Behavior modifies; it never drives.
|
| 67 |
+
"""
|
| 68 |
+
b = rubric["behavioral"]
|
| 69 |
+
sig = loader.get_signals(raw)
|
| 70 |
+
delta = 0.0
|
| 71 |
+
facts: List[str] = []
|
| 72 |
+
|
| 73 |
+
la = loader.parse_date(sig.get("last_active_date"))
|
| 74 |
+
if la:
|
| 75 |
+
days = (REFERENCE_DATE - la).days
|
| 76 |
+
if days <= b["active_recent_days"]:
|
| 77 |
+
delta += 0.05; facts.append(f"active {days}d ago")
|
| 78 |
+
elif days >= b["active_stale_days"]:
|
| 79 |
+
delta -= 0.15; facts.append(f"inactive {days}d")
|
| 80 |
+
elif days >= 120:
|
| 81 |
+
delta -= 0.07; facts.append(f"last active {days}d ago")
|
| 82 |
+
|
| 83 |
+
rr = sig.get("recruiter_response_rate")
|
| 84 |
+
if isinstance(rr, (int, float)):
|
| 85 |
+
if rr >= b["good_response_rate"]:
|
| 86 |
+
delta += 0.04; facts.append(f"{rr:.0%} recruiter response")
|
| 87 |
+
elif rr <= b["weak_response_rate"]:
|
| 88 |
+
delta -= 0.12; facts.append(f"low {rr:.0%} recruiter response")
|
| 89 |
+
elif rr <= 0.3:
|
| 90 |
+
delta -= 0.05
|
| 91 |
+
|
| 92 |
+
if sig.get("open_to_work_flag"):
|
| 93 |
+
delta += 0.02
|
| 94 |
+
if sig.get("verified_email") and sig.get("verified_phone"):
|
| 95 |
+
delta += 0.02
|
| 96 |
+
|
| 97 |
+
ic = sig.get("interview_completion_rate")
|
| 98 |
+
if isinstance(ic, (int, float)) and ic > 0:
|
| 99 |
+
if ic >= b["good_interview_completion"]:
|
| 100 |
+
delta += 0.02
|
| 101 |
+
elif ic < 0.3:
|
| 102 |
+
delta -= 0.06; facts.append(f"low {ic:.0%} interview completion")
|
| 103 |
+
|
| 104 |
+
notice = sig.get("notice_period_days")
|
| 105 |
+
if isinstance(notice, (int, float)):
|
| 106 |
+
if notice <= b["notice_preferred_days"]:
|
| 107 |
+
delta += 0.02
|
| 108 |
+
elif notice > b["notice_acceptable_days"]:
|
| 109 |
+
delta -= 0.05; facts.append(f"{int(notice)}-day notice")
|
| 110 |
+
|
| 111 |
+
mult = float(np.clip(1.0 + delta, b["modifier_floor"], b["modifier_ceiling"]))
|
| 112 |
+
return round(mult, 4), facts
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# component assembly + final score
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
def components(raw: dict, rubric: dict, semantic_fit: float) -> Dict[str, float]:
|
| 120 |
+
tax = features.role_coherence_taxonomy(raw, rubric)
|
| 121 |
+
# role_coherence blends taxonomy (dominant) with semantic fit
|
| 122 |
+
role_coherence = round(0.7 * tax + 0.3 * semantic_fit, 4)
|
| 123 |
+
return {
|
| 124 |
+
"semantic_fit": round(float(semantic_fit), 4),
|
| 125 |
+
"role_coherence": role_coherence,
|
| 126 |
+
"career_evidence": features.career_evidence(raw, rubric),
|
| 127 |
+
"experience_fit": features.experience_fit(raw, rubric),
|
| 128 |
+
"trust_skills": features.trust_skills(raw, rubric),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def base_score(comps: Dict[str, float], rubric: dict, drop: str = None) -> float:
|
| 133 |
+
"""Weighted sum of components. `drop` ablates one component (re-normalising)."""
|
| 134 |
+
w = dict(rubric["component_weights"])
|
| 135 |
+
w.pop("_comment", None)
|
| 136 |
+
if drop and drop in w:
|
| 137 |
+
w.pop(drop)
|
| 138 |
+
total_w = sum(w.values())
|
| 139 |
+
s = sum(comps[k] * wv for k, wv in w.items())
|
| 140 |
+
return s / total_w if total_w else 0.0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def score_candidate(raw: dict, rubric: dict, semantic_fit: float, drop: str = None,
|
| 144 |
+
use_gates: bool = True, use_honeypot: bool = True,
|
| 145 |
+
use_behavior: bool = True) -> dict:
|
| 146 |
+
"""Full scoring record for one candidate (feeds ranking + reasoning).
|
| 147 |
+
|
| 148 |
+
The use_* flags exist for the ablation study in eval/ (e.g. measure NDCG
|
| 149 |
+
with the gates or honeypot filter switched off)."""
|
| 150 |
+
comps = components(raw, rubric, semantic_fit)
|
| 151 |
+
base = base_score(comps, rubric, drop=drop)
|
| 152 |
+
|
| 153 |
+
hp, hp_reasons = honeypot.detect(raw)
|
| 154 |
+
gate_mult, gate_reasons = gates.apply_gates(raw, rubric)
|
| 155 |
+
beh_mult, beh_facts = behavioral_modifier(raw, rubric)
|
| 156 |
+
|
| 157 |
+
eff_gate = gate_mult if use_gates else 1.0
|
| 158 |
+
eff_beh = beh_mult if use_behavior else 1.0
|
| 159 |
+
if hp and use_honeypot:
|
| 160 |
+
final = 0.0
|
| 161 |
+
else:
|
| 162 |
+
final = base * eff_gate * eff_beh
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"candidate_id": loader.candidate_id(raw),
|
| 166 |
+
"components": comps,
|
| 167 |
+
"base": round(base, 4),
|
| 168 |
+
"gate_mult": gate_mult,
|
| 169 |
+
"gate_reasons": gate_reasons,
|
| 170 |
+
"behavior_mult": beh_mult,
|
| 171 |
+
"behavior_facts": beh_facts,
|
| 172 |
+
"honeypot": hp,
|
| 173 |
+
"honeypot_reasons": hp_reasons,
|
| 174 |
+
"final_score": round(float(final), 6),
|
| 175 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace Space (Streamlit) dependencies for the Lighthouse sandbox.
|
| 2 |
+
streamlit==1.40.2
|
| 3 |
+
sentence-transformers==3.3.1
|
| 4 |
+
torch==2.5.1
|
| 5 |
+
numpy==2.2.6
|
| 6 |
+
pandas==2.3.3
|
| 7 |
+
PyYAML==6.0.2
|