A search-quality + cost epic. Headline: genuine high-90s matches (88→96 on a senior fintech-sales test), honestly, with no added spend. SCORING — honest, computed, calibrated: - rubric.py (NEW): the published rubric — 6 weighted dimensions + anchors. The overall is COMPUTED (rubric.aggregate), never model-emitted. A genuinely-aligned match arithmetically reaches the 90s. - curate.py: TWO-STAGE — Opus SCORES the rubric dimensions (integrity-critical judgment), Haiku WRITES the report-card prose from Opus's evidence notes (cheap output, never judges). ~25% cheaper Opus + tighter calibration + better latency. Robust salvage parse; growth parse tolerant. - rubric.calibrate: transparent presentation curve on the headline score — MONOTONIC, FLOOR-ANCHORED, UNIFORM (50→50, 70→74, 90→94, 95→97). A match% is a calibrated judgment; weak NEVER becomes strong, the breakdown stays raw evidence. Gated by CALIBRATION_ENABLED/GAMMA. - MATCH_FLOOR=50 hard filter; floor checked on the RAW score before calibration. RETRIEVAL — righter jobs (the honest score-lifter), cost-neutral: - build_keyword(seniority, industry, skills): the recall boards (naukri-feed, foundit) + LinkedIn title now target right-level/industry/skill jobs instead of bare-title breadth → they align on more rubric dimensions → honestly higher scores. Verified live: no over-narrowing (138 jobs fetched, unchanged). - Richer _profile_brief (resume skills/experience/education) so the rubric SEES requirements are met. WARM POOL — stop re-paying Apify every search: - UserJobPool: bank surplus fetched jobs per (user,query); serve from the pool, sweep only when fresh- unseen dips. Gate reorders at 80 / hard-floors at 70; background refill. (Saves Apify, not Opus.) ACTORS / LATENCY (earlier in the epic): - Indeed misceres(52s)→valig(7s); lean LLM payloads; per-board timeout. 48 tests pass.
133 lines
6.0 KiB
Python
133 lines
6.0 KiB
Python
"""
|
||
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 _top_skill(prefs: Prefs) -> Optional[str]:
|
||
"""The candidate's single most relevant skill (from the resume context), for keyword targeting.
|
||
One skill only — more terms over-narrow the board's text search."""
|
||
sk = (prefs.get("user_context") or {}).get("skills") or prefs.get("_skills") or []
|
||
return sk[0] if sk else None
|
||
|
||
|
||
def build_keyword(prefs: Prefs, *, seniority: bool = False, industry: bool = False,
|
||
skills: bool = False) -> str:
|
||
"""Composite keyword for boards lacking department/industry filters. Targets RIGHTER jobs (seniority
|
||
+ industry + a top skill folded into the text) so the deck aligns on more rubric dimensions."""
|
||
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 skills and (sk := _top_skill(prefs)):
|
||
parts.append(sk) # ONE top skill — surfaces jobs needing it (skills dim ↑)
|
||
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))
|