Engine Phase 3: non-tech scoring (role-aware weights + curator) + vocab

- rank.py: get_weights(profile) — when the profile reads non-tech (sales/finance/HR/ops/support/…),
  tilt the weights to role(0.16)/industry(0.12)/semantic(0.12), drop skill(0.18). Tech weights
  unchanged. (The competency-skill-backfill was tried + REVERTED — uniform per-role competencies
  added no discrimination and Phase 2's real-cosine f_semantic already supplies the non-tech signal.)
- curate.py: non-tech instruction — role/responsibility fit is the spine; a missing skill list is NOT
  a negative; a strong role+industry+responsibility+seniority match is a genuine ~80, not ~65.
- test_contracts.py: allowlist the new keyword-handled non-tech vocab (active boards keyword-fold;
  numeric codes deferred since the active feed/recall paths don't use them).

Regression (120 pairs): MAE 18.4 → 16.1 (all from non-tech: sales 17.1→13.0, ops 16.3→11.7), recall
holds 0.92, tech unchanged. 31 tests pass.
This commit is contained in:
raulgupta
2026-06-25 16:03:20 +05:30
parent eb6ded5b3d
commit 7c5a88dd3f
3 changed files with 36 additions and 6 deletions

View File

@@ -36,6 +36,13 @@ in THIS job's actual skills/title.
Honesty rules: scores reflect reality — a partial match is in the 60s-70s, not the 90s. Ground every note \
in the job's real content; invent nothing. Frame as helpful guidance, not a hiring verdict.
NON-TECH roles (sales, marketing, finance, HR, operations, support, supply-chain, legal, admin): the SPINE \
of the match is role/function fit + responsibility overlap + industry + seniority — NOT a skill-tag checklist. \
These postings rarely list skills, and a missing skill list is NOT a negative signal — score on the \
responsibilities and role alignment, never penalize the candidate for the board's empty skill field. A strong \
role+industry+responsibility+seniority match with no listed tags is a genuine ~80 match, not a ~65. Make one \
breakdown dimension "Role fit" (same function / adjacent / different).
Respond with ONLY a JSON object (no prose, no markdown fences):
{"kept":[{"id","score","fit","archetype","one_line","breakdown":[{"name","score","level","note"}],\
"coach_note","growth":{"text","from","to"}}]}

View File

@@ -53,7 +53,7 @@ def profile_from(prefs: dict, user_context: dict | None = None) -> UserProfile:
countries = [lp["country"].lower() for lp in locs if lp.get("country")]
wm = [w.lower() for w in (prefs.get("workMode") or [])]
title = C.title_of(prefs) or None
skills = S.canon((user_context or {}).get("skills")) # empty in Phase 1 (prefs-only)
skills = S.canon((user_context or {}).get("skills")) # absent for non-tech → semantic (cosine) carries it
toks = {t for t in re.findall(r"[a-z0-9+#]+", f"{title or ''} {' '.join(skills)}".lower()) if len(t) > 2}
return UserProfile(
title=title, skills=skills, years=C.years_of(prefs),
@@ -146,11 +146,29 @@ def f_semantic(p, job):
FACTORS = {"skill": f_skill, "experience": f_experience, "location": f_location, "salary": f_salary,
"industry": f_industry, "role": f_role, "semantic": f_semantic}
# Non-tech identity = role + industry + meaning, NOT a skill checklist (which boards rarely list).
# When the profile reads non-tech, tilt the weights there; skill drops, semantic/role/industry rise.
NON_TECH_FAMILIES = {"sales", "marketing", "finance", "accounting", "hr", "human resource", "operations",
"ops", "customer support", "customer service", "bpo", "legal", "supply chain", "admin",
"business development"}
NONTECH_WEIGHTS = {"skill": 0.18, "experience": 0.22, "location": 0.14, "salary": 0.06,
"industry": 0.12, "role": 0.16, "semantic": 0.12}
def _is_nontech(p: UserProfile) -> bool:
blob = " ".join([*(r.lower() for r in p.roles), (p.title or "").lower()])
return any(fam in blob for fam in NON_TECH_FAMILIES)
def get_weights(p: UserProfile) -> dict:
return NONTECH_WEIGHTS if _is_nontech(p) else WEIGHTS
def utility(p: UserProfile, job: dict):
w = get_weights(p)
factors = {k: fn(p, job) for k, fn in FACTORS.items()}
num = sum(WEIGHTS[k] * v for k, v in factors.items() if v is not None)
den = sum(WEIGHTS[k] for k, v in factors.items() if v is not None)
num = sum(w[k] * v for k, v in factors.items() if v is not None)
den = sum(w[k] for k, v in factors.items() if v is not None)
U = (num / den) if den else None # NO present signals → None (filtered out)
return U, factors

View File

@@ -61,9 +61,14 @@ def test_overlap_none_when_a_side_is_empty():
# ── 4. Frontend↔backend EXACT-STRING vocab contract ──────────────────────────
# Values the UI may send that are intentionally NOT board codes (keyword-only / "open").
_ROLE_KEYWORD_ONLY = {"Open to any"}
_INDUSTRY_KEYWORD_ONLY = {"Any", "Open to any", ""}
# Values the UI sends that are intentionally NOT numeric board codes — they fold into the keyword.
# The ACTIVE boards (naukri-feed, linkedin-recall, foundit, indeed, workindia) all search by keyword +
# city NAME, so the non-tech vocab below works WITHOUT a code (the code maps are only for the off,
# code-based actors). Codes for these are deferred per plan; listed here so drift still fails loudly.
_ROLE_KEYWORD_ONLY = {"Open to any", "Finance", "HR", "Customer Support", "Supply Chain",
"Business Development", "Legal"}
_INDUSTRY_KEYWORD_ONLY = {"Any", "Open to any", "", "Banking", "Insurance", "Retail", "FMCG",
"Pharma", "Manufacturing", "Logistics"}
_SCOUT_TS = Path(__file__).resolve().parents[2] / "growqr-demo/frontend/src/scout/data/scout.ts"