Files
matchmaking-v2/app/engine/rubric.py
raulgupta 13faed0a06 Engine: signal-weighted India stack + cursorless pool + honest-card hardening
Boards (signal-weighted, ~150/sweep):
- Add TimesJobs (shahidirfan) — richest board: skills+salary+experience+offsite. Build/normalize +
  register; legacy city alias (New Delhi→Delhi, Bengaluru→Bangalore, Gurugram→Gurgaon) — verified live
  it returns 0 for "New Delhi", 35 for "Delhi".
- Swap LinkedIn harvestapi → curious_coder (structured industry/jobFunction/applicants); harvestapi kept
  registered as linkedin_v1 fallback.
- Drop Indeed from enabled (0% skills, redundant). Budgets: Foundit 40, TimesJobs 35, Naukri 30,
  LinkedIn 30, WorkIndia 15.

Pool — cursorless freshness + safety:
- REMOVE pagination/cursors: curious_coder only honors start=0 (start>0 → empty), proven; the rest are
  date feeds. Freshness now = boards' date-sort + the per-user seen-net + pool_save id-dedup.
- Exhaustion guard: a refill adding < POOL_MIN_NEW_PER_REFILL new jobs flags the (user,query) exhausted →
  the gate relaxes the floor instead of block-fetching dupes (makes the 100/90 floor safe on niche queries).
- Storage cap: pool_save trims beyond POOL_MAX_PER_QUERY freshest (kills DB swell). 72h TTL verified.
- Thresholds 100/90 (deep pool, pay-for-volume).

Scoring / cards:
- Evidence-based fallback prose: when Haiku's cards stage gives nothing, the card uses Opus's REAL
  dimension notes (not a generic "Strong on X") + coverage logging + generic salvage parser.
- "Skills & requirements" → "Skills fit" (consistent dimension labels).

Bug fixes:
- mark_seen dedups ids (was CardinalityViolationError on a duplicate id in one batch).

Tests: deep stack contracts + committed board-sample fixture (no cache-pollution flakiness) + offline
e2e + opt-in live e2e. 64 pass.
2026-06-26 15:56:27 +05:30

67 lines
4.0 KiB
Python

"""The match rubric — the ONE published source of truth for how a fit score is computed.
Integrity rule: the displayed score is NEVER emitted by a model. The curator (Opus) assesses each
DIMENSION 0-100 against a fixed ANCHOR (observable facts, not vibes); `aggregate()` combines them with
FIXED weights into the overall score. So a 94 is *earned* — role 100, location 100, skills 88, … — and
every score decomposes + audits. There is no prompt knob that lifts the number without the evidence.
"""
from __future__ import annotations
# key · label (shown in the breakdown) · weight · the ANCHOR the curator scores against. Weights sum to 1.
DIMENSIONS = [
{"key": "role", "label": "Role fit", "weight": 0.25,
"anchor": "100 = same function · 75 = adjacent function · 45 = different function · 15 = unrelated"},
{"key": "skills", "label": "Skills fit", "weight": 0.25,
"anchor": "share of the job's stated requirements the candidate genuinely meets (100 = all · 50 = half · 0 = none)"},
{"key": "seniority", "label": "Seniority fit", "weight": 0.15,
"anchor": "100 = same level · 70 = one band off · 35 = two+ bands off"},
{"key": "location", "label": "Location fit", "weight": 0.15,
"anchor": "100 = target city or remote-ok · 60 = same metro/region · 25 = different city"},
{"key": "industry", "label": "Industry fit", "weight": 0.10,
"anchor": "100 = same domain · 65 = adjacent domain · 30 = unrelated domain (omit if no industry preference)"},
{"key": "experience", "label": "Experience fit", "weight": 0.10,
"anchor": "100 = years + trajectory align · 60 = slightly under/over · 30 = large gap"},
]
KEYS = [d["key"] for d in DIMENSIONS]
WEIGHTS = {d["key"]: d["weight"] for d in DIMENSIONS}
LABELS = {d["key"]: d["label"] for d in DIMENSIONS}
def aggregate(scores: dict) -> int:
"""{dimension_key: 0-100} → the weighted overall (0-100). Only dimensions actually scored count; a
genuinely-absent one (e.g. industry with no preference) drops out and its weight redistributes over
the rest — floor-free: never a silent zero that tanks the score, never a constant that props it up.
The five core dimensions (role/skills/seniority/location/experience) always apply, so this can't be
gamed by omitting the hard ones — the curator is required to score every applicable dimension."""
present = {k: max(0.0, min(100.0, float(scores[k]))) for k in KEYS if scores.get(k) is not None}
if not present:
return 0
wsum = sum(WEIGHTS[k] for k in present)
return round(sum(WEIGHTS[k] * v for k, v in present.items()) / wsum)
def calibrate(raw: float, *, floor: float = 50.0, gamma: float = 1.3) -> int:
"""Transparent presentation calibration of the HEADLINE score (the per-dimension breakdown stays raw
evidence). A match% is a calibrated product judgment, not a physical measurement — so we apply ONE
documented curve, anchored at the floor and gently expanded at the top, so a genuinely-strong match
presents in the 90s. Guardrails that keep it honest:
• MONOTONIC — strictly increasing, so a better job ALWAYS scores higher (ordering never lies).
• FLOOR-ANCHORED — a weak match at the floor is unchanged; weak NEVER becomes strong.
• UNIFORM — the same curve for every job (no cherry-picking).
Example (gamma 1.3): 50→50, 60→63, 70→74, 80→85, 90→94, 95→97. Raw stays as-is below the floor."""
if raw <= floor:
return round(raw)
span = 100.0 - floor
return round(100.0 - span * ((100.0 - raw) / span) ** gamma)
def level_for(score: float) -> str:
"""Strong / Solid / Light band for a dimension (drives the breakdown chips)."""
return "Strong" if score >= 72 else ("Solid" if score >= 52 else "Light")
def prompt_block() -> str:
"""The rubric rendered for the curator prompt: the exact dimensions to score, with their anchors."""
return "\n".join(f' - "{d["key"]}": {d["label"]}{d["anchor"]}' for d in DIMENSIONS)