""" Input coercion layer — turns the frontend `ScoutPrefs` (JSON dict) into each board actor's exact input schema. See `docs/ENGINE_DESIGN.md` §1.2. All maps verified against the live Apify input schemas (2026-06-17). GOLDEN RULE: a value that doesn't resolve to a board enum/code is NEVER sent as an invalid enum — it degrades to a keyword fold (or post-filter) and is flagged. Coverage gaps lose precision, never the call. """ from __future__ import annotations import re from typing import Any, Optional Prefs = dict[str, Any] # ── lookup maps (verified codes/enums) ─────────────────────────────────────── COUNTRY_ISO2 = { # Indeed `country` "India": "IN", "United States": "US", "United Kingdom": "GB", "Canada": "CA", "Australia": "AU", "Germany": "DE", "Singapore": "SG", "UAE": "AE", } WORKMODE_NAUKRI = {"Remote": "remote", "Hybrid": "hybrid", "On-site": "office"} WORKMODE_LINKEDIN = {"Remote": "remote", "Hybrid": "hybrid", "On-site": "office"} SENIORITY_YEARS = {"Fresher": 0, "Junior": 2, "Mid-level": 5, "Senior": 9, "Lead": 13} SENIORITY_LINKEDIN = { # LinkedIn `experienceLevel` "Fresher": "entry", "Junior": "associate", "Mid-level": "mid-senior", "Senior": "director", "Lead": "executive", } COMP_NAUKRI_SALARY = { # ₹ band → Naukri `salaryRange` (lakhs bucket) "₹10–14L": "10to15", "₹14–18L": "15to25", "₹16–22L": "15to25", "₹22–30L": "25to50", "₹30L+": "50to75", } COMPANY_TYPE_NAUKRI = { # loose — funding stage ≠ company type "Seed / early": "Startup", "Growth-stage": "Startup", "Late / pre-IPO": "Corporate", "Enterprise": "MNC", } # Naukri numeric codes (labels are display-only — the actor wants the code) ROLE_NAUKRI_DEPT = { # role area → `department` "Product": 10, "Engineering": 5, "Design": 15, "Data": 3, "Marketing": 9, "Sales": 14, "Ops": 2, # "Open to any" → omit } INDUSTRY_NAUKRI = { # → `industry` "Fintech": 114, "SaaS": 110, "E-commerce": 108, "EdTech": 133, "HealthTech": 131, "Dev tools": 110, "Consulting": 126, } CITY_NAUKRI = { # → `cities` (1→N for metro regions) "Bengaluru": [97], "Bangalore": [97], "Mumbai": [134], "New Delhi": [6], "Delhi": [6], "Delhi NCR": [6, 73, 220, 350], "Gurgaon": [73], "Gurugram": [73], "Noida": [220], "Hyderabad": [17], "Chennai": [183], "Pune": [139], "Kolkata": [232], } # LinkedIn industry IDs — best-effort, TODO verify vs LinkedIn taxonomy; unmapped → keyword fold INDUSTRY_LINKEDIN_ID = { "Fintech": "1742", "SaaS": "4", "E-commerce": "6", "EdTech": "68", "HealthTech": "14", "Dev tools": "4", "Consulting": "11", } # ── parsing + accessors ────────────────────────────────────────────────────── def parse_location(loc: str) -> dict: """'Bengaluru · India' → {city, country, remote, anywhere}.""" s = (loc or "").strip() low = s.lower() if low == "remote": return {"city": None, "country": None, "remote": True, "anywhere": False} if low in ("anywhere", ""): return {"city": None, "country": None, "remote": False, "anywhere": True} parts = [p.strip() for p in re.split(r"[·,]", s) if p.strip()] return { "city": parts[0] if parts else None, "country": parts[1] if len(parts) > 1 else None, "remote": False, "anywhere": False, } def parsed_locations(prefs: Prefs) -> list[dict]: locs = [parse_location(l) for l in (prefs.get("location") or [])] return locs or [{"city": None, "country": None, "remote": False, "anywhere": True}] def is_remote(prefs: Prefs) -> bool: return any(l["remote"] for l in parsed_locations(prefs)) or "Remote" in (prefs.get("workMode") or []) def title_of(prefs: Prefs) -> str: """Keyword precedence: specific fine-tune pick → typed title → first concrete role.""" tt = prefs.get("targetTitles") or [] if tt: return tt[0] if (prefs.get("title") or "").strip(): return prefs["title"].strip() for r in (prefs.get("role") or []): if r and r != "Open to any": return r return "" def seniority_band(prefs: Prefs) -> Optional[str]: exp = prefs.get("experience") or [] return exp[0] if exp else None def years_of(prefs: Prefs) -> Optional[int]: """Numeric years: explicit `years` → else derived from the seniority band.""" y = prefs.get("years") if isinstance(y, (int, float)): return max(0, min(30, int(y))) b = seniority_band(prefs) return SENIORITY_YEARS.get(b) if b else None def build_keyword(prefs: Prefs, *, seniority: bool = False, industry: bool = False) -> str: """Composite keyword for boards lacking department/industry filters.""" parts: list[str] = [] if seniority and seniority_band(prefs) in ("Senior", "Lead"): parts.append(seniority_band(prefs)) # prefix only the high bands parts.append(title_of(prefs)) if industry: inds = prefs.get("industry") or [] if inds and inds[0] not in ("Any", None): parts.append(inds[0]) return " ".join(p for p in parts if p).strip() def has_mvq(prefs: Prefs) -> bool: """Minimum Viable Query = a title + a resolvable location (city/country/remote).""" if not title_of(prefs): return False return any(l["city"] or l["country"] or l["remote"] for l in parsed_locations(prefs))