Files
raulgupta 7c2640a999 Engine: honest high-90s scoring + warm pool + 2-stage curation (cost-down)
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.
2026-06-26 12:48:06 +05:30

133 lines
6.0 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 _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))