diff --git a/app/engine/curate.py b/app/engine/curate.py index 0a176ea..272cf6a 100644 --- a/app/engine/curate.py +++ b/app/engine/curate.py @@ -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"}}]} diff --git a/app/engine/rank.py b/app/engine/rank.py index a94571d..38f19e9 100644 --- a/app/engine/rank.py +++ b/app/engine/rank.py @@ -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 diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 3b40496..23e16ef 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -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"