Files
matchmaking-v2/app/engine/board_adapters/adapters.py
raulgupta 04e6fb64bb Engine: regression harness (Phase 0) + India non-tech actors (Phase 1)
Phase 0 — measurement gate:
- tests/test_contracts.py: BOARDS↔NORMALIZERS alignment, funnel-shape guards, overlap() None
  contract, and the frontend↔backend EXACT-STRING vocab check (scout.ts options must resolve in
  coerce.py, else silent keyword-fold).
- tests/run_regression.py + fixtures/regression_set.json (+ _seed_regression.py): a frozen 120-pair
  set (tech + non-tech) Opus-judged for recruiter-fit targets. Two metrics: score MAE and SIFT TOP-K
  MEMBERSHIP RECALL (do the best jobs reach Opus?). Baseline: MAE 24.7, recall 0.68 — i.e. ~32% of
  the genuinely-best jobs are cut before the curator ever sees them (worse for non-tech).

Phase 1 — coverage (India non-tech):
- Enable Indeed (misceres) + enrich indeed_to_scoutjob with details.description + location_mode
  (jobType is employment type, not skills → required_skills now []).
- Add WorkIndia (shahidirfan) — India blue/grey-collar non-tech: build_workindia_input +
  workindia_to_scoutjob (real skills + description; per-job apply URL from job_id since source_url
  is generic and would dedup-collapse the deck).
- Drop Wellfound from the stack (US-startup-heavy + 400s); kept registered-but-off.
- BOARDS_ENABLED = naukri,foundit,linkedin,indeed,workindia. Pool ~90 → ~140 jobs/search.
Regression scores unchanged (engine scoring untouched). 31 tests pass.
2026-06-25 15:44:28 +05:30

259 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Per-board query builders — `ScoutPrefs` (dict) → each Apify actor's input.
See `docs/ENGINE_DESIGN.md` §1.2. Returns the `input` dict you POST to the actor's
`run-sync-get-dataset-items` endpoint.
System knobs (budget / freshness) are arguments with sane defaults, not user inputs.
"""
from __future__ import annotations
from typing import Any
from . import coerce as C
Prefs = dict[str, Any]
# ── Naukri (India workhorse — richest structured filters) ────────────────────
def build_naukri_input(prefs: Prefs, *, max_jobs: int = 80, freshness: str = "7", recall: bool = False) -> dict:
# recall=True (retrieve stage): keep keyword + city + work-mode only; drop the
# narrowing filters (experience/department/industry/salary/stage) — those become
# ENGINE ranking later, not hard board cuts. Wider freshness for recall.
if recall:
freshness = "30"
locs = C.parsed_locations(prefs)
cities: list[int] = []
for lp in locs:
if lp["city"]:
cities += C.CITY_NAUKRI.get(lp["city"], []) # unmapped city → dropped (kept in keyword)
out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"maxJobs": max_jobs, # REQUIRED — the budget
"freshness": freshness, # liveness gate
"sortBy": "date",
"fetchDetails": True, # → apply redirect / full details
}
if cities:
out["cities"] = [str(c) for c in sorted(set(cities))] # Naukri select codes are STRINGS
wm = [C.WORKMODE_NAUKRI[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_NAUKRI]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workMode"] = wm
if recall:
return out # retrieve stage stops here — keyword + cities + work-mode only
yrs = C.years_of(prefs)
if yrs is not None:
out["experience"] = str(yrs) # numeric-string enum "0".."30"
depts = [C.ROLE_NAUKRI_DEPT[r] for r in (prefs.get("role") or []) if r in C.ROLE_NAUKRI_DEPT]
if depts:
out["department"] = [str(d) for d in sorted(set(depts))] # codes → strings
inds = [C.INDUSTRY_NAUKRI[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_NAUKRI]
if inds:
out["industry"] = [str(i) for i in sorted(set(inds))] # codes → strings
if prefs.get("targetComp") in C.COMP_NAUKRI_SALARY:
out["salaryRange"] = [C.COMP_NAUKRI_SALARY[prefs["targetComp"]]]
if prefs.get("companyStage") in C.COMPANY_TYPE_NAUKRI:
out["companyType"] = [C.COMPANY_TYPE_NAUKRI[prefs["companyStage"]]]
return out
# ── Naukri (blackfalcondata feed — city-precise, real offsite applyRedirectUrl) ──
def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details: bool = True) -> dict:
# City-filtered at the board (hyper-relevant + cheap). fetch_details=True surfaces the
# offsite `applyRedirectUrl`; skipReposts dedups WITHIN a run. Narrowing (dept/industry/exp)
# stays off here — that's engine ranking later.
# NOTE: no `incremental`/`stateKey` — that put opaque, exhausting, cross-account dedup state on
# Apify. Cross-deck dedup is the PER-USER seen-net in our DB. (The feed actor has no page/offset.)
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
return {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city, # precise city, e.g. "New Delhi"
"maxResults": max_jobs,
"fetchDetails": fetch_details,
"skipReposts": True,
"sortBy": "date",
}
# ── Foundit / Monster India (city-precise; apply_url is the offsite redirect) ──
# Foundit uses legacy city names — it returns 0 for "New Delhi"/"Bengaluru".
_FOUNDIT_CITY = {
"new delhi": "Delhi", "delhi ncr": "Delhi", "bengaluru": "Bangalore",
"gurugram": "Gurgaon", "mumbai": "Mumbai", "pune": "Pune", "hyderabad": "Hyderabad",
"chennai": "Chennai", "kolkata": "Kolkata", "noida": "Noida",
}
def build_foundit_input(prefs: Prefs, *, results_wanted: int = 50) -> dict:
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
city = _FOUNDIT_CITY.get(city.lower(), city) # alias to Foundit's canonical name
return {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city,
"results_wanted": results_wanted,
}
# ── LinkedIn (takes the band, not years; ₹ comp incompatible) ────────────────
def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "week",
recall: bool = False, page: int = 1) -> dict:
# recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing
# filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which
# become ENGINE ranking later. City-level location + a wrong industryId zero the actor out.
out: dict[str, Any] = {
"jobTitles": [C.title_of(prefs)],
"maxItems": max_items,
"sortBy": "date",
"page": page, # Start Page Number — advance per re-run for NEW jobs
}
cities: list[str] = [] # bare city names ("New Delhi") — harvestapi's location format
cities_q: list[str] = [] # city, country ("New Delhi, India") — used outside recall
countries: list[str] = []
for lp in C.parsed_locations(prefs):
if lp["country"] and lp["country"] not in countries:
countries.append(lp["country"])
if lp["city"]:
cities.append(lp["city"])
cities_q.append(f"{lp['city']}, {lp['country']}" if lp["country"] else lp["city"])
if recall:
# City-level for precision (engine ranks finer later). Fall back to country if no city.
out["locations"] = cities or countries
return out
out["postedLimit"] = posted # freshness
out["easyApply"] = False # favour offsite (auto-applyable) over login-gated easy-apply
if cities_q or countries:
out["locations"] = cities_q or countries
# TODO: geoIds = [lookup(loc)] is more reliable than free-text locations.
wm = [C.WORKMODE_LINKEDIN[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_LINKEDIN]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workplaceType"] = wm
band = C.seniority_band(prefs)
if band in C.SENIORITY_LINKEDIN:
out["experienceLevel"] = [C.SENIORITY_LINKEDIN[band]]
ind_ids = [C.INDUSTRY_LINKEDIN_ID[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_LINKEDIN_ID]
if ind_ids:
out["industryIds"] = ind_ids # TODO verify IDs; unmapped industries already fall to keyword
return out
# ── Indeed (blunt: what + where + country only; everything else folds in) ────
def build_indeed_input(prefs: Prefs, *, max_items: int = 80) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"] or l["country"]), {})
country_name = loc.get("country") or "India"
return {
# Indeed has no work-mode/seniority/industry params → fold them into the query.
"position": C.build_keyword(prefs, seniority=True, industry=True),
"location": loc.get("city") or "",
"country": C.COUNTRY_ISO2.get(country_name, "IN"),
"maxItemsPerSearch": max_items,
"followApplyRedirects": True, # → real offsite apply URL
"saveOnlyUniqueItems": True,
}
def build_indeed_searches(prefs: Prefs, *, max_items: int = 80) -> list[dict]:
"""Fan-out: Indeed `position`/`location` are single strings, so multi title×city
becomes N runs to merge+dedup downstream."""
titles = (prefs.get("targetTitles") or [])[:3] or [C.title_of(prefs)]
cities = [l["city"] for l in C.parsed_locations(prefs) if l["city"]] or [None]
base = build_indeed_input(prefs, max_items=max_items)
runs = []
for t in titles:
for city in cities:
r = dict(base)
r["position"] = " ".join(x for x in [t, *(["remote"] if C.is_remote(prefs) else [])] if x)
if city:
r["location"] = city
runs.append(r)
return runs
# ── Direct-ATS (curated-company lane — `companies` is passed in, NOT from prefs) ──
def build_ats_input(prefs: Prefs, *, companies: list[dict], recent_days: int = 30,
max_per_company: int = 50, output_profile: str = "compact",
recall: bool = False) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"]), {})
inds = [i for i in (prefs.get("industry") or []) if i and i != "Any"]
out: dict[str, Any] = {
"companies": companies, # curated [{company}] — the lane's identity
"titleKeyword": C.title_of(prefs), # filters within those companies (role relevance)
"remoteOnly": C.is_remote(prefs),
"keywords": inds, # loose industry fold
"recentWindowDays": recent_days,
"maxJobsPerCompany": max_per_company,
"outputProfile": output_profile, # "compact" = cheaper (enough for display); "full" adds descriptions
}
# recall=True: skip the city filter — these curated companies are global/remote, so a
# city lock ("Bengaluru") empties the deck. Location becomes engine ranking later.
if not recall:
out["locationKeyword"] = loc.get("city", "")
return out
# ── WorkIndia (India blue/grey-collar — the non-tech workhorse: sales, BPO, retail, field, ops) ──
def build_workindia_input(prefs: Prefs, *, results_wanted: int = 20) -> dict:
# keyword + city; includeDetails=True pulls the real skills + full description (verified live Phase 0).
# No page param → per-user seen-net dedups. City is a lowercased name slug ("mumbai", "pune").
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"results_wanted": results_wanted,
"includeDetails": True,
}
if city:
out["city"] = city.lower()
return out
# ── Wellfound / AngelList (startup roles — equity + skills; remote-friendly for India) ──
def build_wellfound_input(prefs: Prefs, *, max_results: int = 15) -> dict:
# query=title; city → location slug (lowercase-hyphen); remote honored. enrichDetail=True is
# REQUIRED — it pulls skills[] + the full description the curator scores on (the core fields
# alone are thin). No page param → dedup via the per-user seen-net (like Naukri/Foundit).
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
out: dict[str, Any] = {
"query": C.title_of(prefs) or C.build_keyword(prefs),
"maxResults": max_results,
"enrichDetail": True, # → skills + description (the curator's signal)
"remote": C.is_remote(prefs),
}
if city:
out["location"] = city.lower().replace(" ", "-") # "New Delhi" → "new-delhi"
return out
if __name__ == "__main__":
import json
demo: Prefs = {
"targetTitles": ["Payments PM"], "title": "Associate Product Manager",
"role": ["Product"], "industry": ["Fintech"],
"location": ["Bengaluru · India", "Delhi NCR · India"],
"workMode": ["Hybrid"], "experience": ["Junior"], "years": 2,
"targetComp": "₹1622L", "companyStage": "Growth-stage",
# engine-side only (ignored by adapters): priorities/stretch/intent/availability/dealBreakers/sort
}
print("MVQ ok:", C.has_mvq(demo), "\n")
for name, fn in [("NAUKRI", build_naukri_input), ("LINKEDIN", build_linkedin_input),
("INDEED", build_indeed_input)]:
print(f"── {name} ──"); print(json.dumps(fn(demo), indent=2, ensure_ascii=False), "\n")
print("── DIRECT-ATS ──")
print(json.dumps(build_ats_input(demo, companies=[{"ats": "ashby", "company": "razorpay"}]), indent=2))