Files
matchmaking-v2/app/engine/board_adapters/coerce.py
raulgupta 89ca9ad647 Initial commit: matchmaking-v2 on-demand multi-board job-search agent
On-demand Scout service (replaces the nightly aggregator):
- FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks
  + Redis-stream worker matching the sibling-service pattern)
- run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit +
  LinkedIn), city-filtered at the board, cheapest-per-result first
- per-board adapters + normalizers -> ScoutJob with rich read-only details and
  offsite apply links; recall mode + MVQ guard
- result cache for dev replay ($0); per-board cost knobs; retry-on-5xx
- research/: engine design, board cost economics, India-actor shortlist, POC
2026-06-18 19:22:03 +05:30

122 lines
5.4 KiB
Python
Raw Permalink 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.

"""
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)
"₹1014L": "10to15", "₹1418L": "15to25", "₹1622L": "15to25",
"₹2230L": "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))