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.
This commit is contained in:
@@ -100,36 +100,52 @@ class MatchmakingAgentSession:
|
||||
await self._stub("opportunity_detail", "return match-score breakdown")
|
||||
|
||||
async def handle_run_search(self, params: dict):
|
||||
"""Slice-1a: ScoutPrefs → live multi-board fetch → cards. No engine, no user-arm."""
|
||||
"""ScoutPrefs → warm-pool gate (serve banked jobs; sweep Apify only when the pool dips) →
|
||||
sift → Opus curate → cards."""
|
||||
from app.engine import search
|
||||
from app.config import get_settings
|
||||
|
||||
prefs = params # the frontend's ScoutPrefs arrive as params (user_context, if present, is ignored)
|
||||
fresh = bool(prefs.get("_fresh")) # dev toggle: force a live sweep instead of replaying cache
|
||||
fresh = bool(prefs.get("_fresh")) # dev toggle: force a live sweep instead of serving the pool
|
||||
if not search.has_mvq(prefs):
|
||||
await self.push("agent_data", action="search_complete",
|
||||
data={"opportunities": [], "needs": ["title", "location"]})
|
||||
return
|
||||
await self.push("agent_thinking",
|
||||
message="Fetching fresh roles across job boards…" if fresh
|
||||
else "Scanning live roles across job boards…")
|
||||
# Per-board search cursors: re-running the SAME query advances EACH board's own page so every
|
||||
# run fetches GENUINELY NEW jobs. Resets when the query changes OR >24h (boards refresh ~daily).
|
||||
# Dedup is the per-user seen-net below — never cross-user, no opaque actor-side state.
|
||||
from app.db import repo as _repo
|
||||
s = get_settings()
|
||||
sig = _repo.search_signature(prefs)
|
||||
cursors = await _repo.get_search_cursors(self.user_id, sig)
|
||||
prefs["_cursors"] = cursors
|
||||
# Heartbeat through the long Apify scrape so the relay channel stays alive (see _with_heartbeat).
|
||||
sweep = await self._with_heartbeat(
|
||||
search.run_sweep(prefs, fresh=fresh),
|
||||
["Scanning live roles across the boards…", "Pulling the latest Naukri + LinkedIn + Foundit roles…",
|
||||
"Gathering fresh postings for you…"])
|
||||
# Safety net: drop anything already shown / applied / dismissed (covers boards that can't
|
||||
# paginate, so the same posting never reappears even if a board re-returns it).
|
||||
seen = await _repo.get_seen_ids(self.user_id)
|
||||
candidates = [o for o in sweep["opportunities"] if o.get("id") not in seen]
|
||||
# Engine: assemble FULL context (profile + prefs). Cheap rankers SIFT 90 → top ~18 (white-box ‖
|
||||
# embedding vibe); then Opus reads only those and curates the honest shortlist + report cards.
|
||||
|
||||
# ── Warm-pool gate ──────────────────────────────────────────────────────────────────────────
|
||||
# Bank surplus fetched jobs per (user, query); serve from the pool and re-pay Apify only when the
|
||||
# fresh-unseen count dips below the floor. Reorder EARLY (background top-up) so it never runs thin.
|
||||
pooled = await _repo.pool_load_fresh_unseen(self.user_id, sig, seen) if s.POOL_ENABLED else []
|
||||
decision = search.pool_decision(len(pooled), fresh, s.POOL_SAFETY_FLOOR, s.POOL_REORDER_AT)
|
||||
cursors = await _repo.get_search_cursors(self.user_id, sig)
|
||||
|
||||
if decision == "sweep":
|
||||
# Pool below the hard floor (or forced): blocking sweep → bank ALL of it → advance cursors.
|
||||
await self.push("agent_thinking",
|
||||
message="Fetching fresh roles across job boards…" if fresh
|
||||
else "Scanning live roles across job boards…")
|
||||
prefs["_cursors"] = cursors
|
||||
sweep = await self._with_heartbeat(
|
||||
search.run_sweep(prefs, fresh=fresh),
|
||||
["Scanning live roles across the boards…", "Pulling the latest Naukri + LinkedIn + Foundit roles…",
|
||||
"Gathering fresh postings for you…"])
|
||||
await _repo.pool_save(self.user_id, sig, sweep["opportunities"])
|
||||
cursors = {b: int(cursors.get(b, 0)) + 1
|
||||
for b in (sweep.get("sources") or {}) if b in search.PAGINATING_BOARDS}
|
||||
pooled = await _repo.pool_load_fresh_unseen(self.user_id, sig, seen)
|
||||
sources = sweep["sources"]
|
||||
else:
|
||||
# Pool healthy → serve from the shelf, zero Apify. (Cursors unchanged; refill, if any, is bg.)
|
||||
await self.push("agent_thinking", message="Pulling your latest matches…")
|
||||
sources = {"pool": len(pooled)}
|
||||
|
||||
candidates = pooled
|
||||
# Engine: cheap rankers SIFT the pool → top ~22 (white-box ‖ embedding vibe); then Opus reads only
|
||||
# those and curates the honest shortlist + report cards.
|
||||
from app.engine import curate as _curate
|
||||
from app.engine import rank as _rank
|
||||
from app.engine import sift as _sift
|
||||
@@ -148,19 +164,14 @@ class MatchmakingAgentSession:
|
||||
engine = f"fallback:{dbg['mode']}"
|
||||
else:
|
||||
engine = "opus"
|
||||
result = {"opportunities": curated, "sources": sweep["sources"],
|
||||
result = {"opportunities": curated, "sources": sources,
|
||||
"shortlisted": len(curated), "scanned": dbg["scored"], "engine": engine,
|
||||
"has_profile": bool(user_context and user_context.get("skills"))}
|
||||
# Persist the RAW deck + ADVANCE every board that ran (its page +1) so the next run of this
|
||||
# query fetches new jobs. Per-opportunity state (applied/dismissed/docs) lives separately.
|
||||
# Persist the deck + the cursors (advanced only if we swept this run; unchanged on a pool-serve —
|
||||
# the cursor advances exactly once per real fetch, foreground or background).
|
||||
clean_prefs = {k: v for k, v in prefs.items()
|
||||
if k not in ("user_context", "_fresh", "_cursors")}
|
||||
# Store the LAST page fetched per paginating board (last + 1 = the page we just pulled). So a
|
||||
# NEW query's first search stores 1 (page 1), not 2. Naukri/Foundit have no page param → no
|
||||
# cursor (they dedup via the per-user seen-net).
|
||||
next_cursors = {b: int(cursors.get(b, 0)) + 1
|
||||
for b in (sweep.get("sources") or {}) if b in search.PAGINATING_BOARDS}
|
||||
await _repo.save_feed(self.user_id, clean_prefs, result, query_sig=sig, cursors=next_cursors)
|
||||
await _repo.save_feed(self.user_id, clean_prefs, result, query_sig=sig, cursors=cursors)
|
||||
# Accumulate the salary band — each deck contributes its peak salary, averaged over decks
|
||||
# (survives decks that disclose nothing, like Naukri "Not disclosed").
|
||||
await _repo.update_salary_band(self.user_id, [o.get("salary_lpa") for o in (result["opportunities"] or [])])
|
||||
@@ -172,6 +183,27 @@ class MatchmakingAgentSession:
|
||||
result["shortlisted"] = len(result["opportunities"])
|
||||
await self.push("agent_data", action="search_complete", data=result)
|
||||
|
||||
# The user already has their deck — if the pool dipped below the reorder mark, top it up OUT OF
|
||||
# BAND so the next search stays free and the shelf never runs thin. (No await: fire-and-forget.)
|
||||
if decision == "pool+refill":
|
||||
asyncio.create_task(self._background_refill(clean_prefs, sig))
|
||||
|
||||
async def _background_refill(self, prefs: dict, sig: str):
|
||||
"""Proactive pool top-up: sweep the next page in the background + bank it, so the fresh-unseen
|
||||
count climbs back above the reorder mark before the user's next search. Best-effort, non-fatal."""
|
||||
from app.engine import search
|
||||
from app.db import repo as _repo
|
||||
try:
|
||||
cursors = await _repo.get_search_cursors(self.user_id, sig)
|
||||
sweep = await search.run_sweep({**prefs, "_cursors": cursors}, fresh=True)
|
||||
await _repo.pool_save(self.user_id, sig, sweep["opportunities"])
|
||||
advanced = {b: int(cursors.get(b, 0)) + 1
|
||||
for b in (sweep.get("sources") or {}) if b in search.PAGINATING_BOARDS}
|
||||
await _repo.save_cursors(self.user_id, sig, advanced)
|
||||
logger.info("pool refill (bg): +%d jobs, sig=%s", len(sweep.get("opportunities") or []), sig[:8])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("background refill failed (non-fatal): %s", e)
|
||||
|
||||
async def handle_suggest_bubbles(self, params: dict):
|
||||
"""Fine-tune bubbling — stage-aware (broad→narrow→role) bubbles from the picks so far."""
|
||||
from app.engine import suggest
|
||||
|
||||
@@ -40,8 +40,13 @@ class Settings(BaseSettings):
|
||||
SUGGEST_MODEL: str = "claude-haiku-4-5" # the bubbling engine — fast + cheap (latency-sensitive UI)
|
||||
EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI — gateway has no embeddings route)
|
||||
ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net)
|
||||
SIFT_TOP_K: int = 28 # candidate pool the curator reads (widened from 18 —
|
||||
# the funnel was cutting ~32% of the best jobs before Opus)
|
||||
SIFT_TOP_K: int = 22 # candidate pool the curator reads — widened from 18 for
|
||||
# recall, trimmed from 28 to keep the Opus payload lean
|
||||
MATCH_FLOOR: int = 50 # HARD filter — never surface a match scored below this
|
||||
# Presentation calibration of the HEADLINE score (breakdown stays raw evidence). Transparent +
|
||||
# monotonic + floor-anchored: a genuinely-strong match presents in the 90s; weak NEVER becomes strong.
|
||||
CALIBRATION_ENABLED: bool = True
|
||||
CALIBRATION_GAMMA: float = 1.3 # 50→50, 70→74, 90→94, 95→97 (see rubric.calibrate)
|
||||
|
||||
# Result cache (dev cost-saver). Keyed by (actor, exact input) → saved job set on disk.
|
||||
# off = always live, never save (PRODUCTION default)
|
||||
@@ -54,6 +59,16 @@ class Settings(BaseSettings):
|
||||
# (hyper-relevant) and surface offsite apply links. Naukri=blackfalcondata feed,
|
||||
# Foundit=Monster India, LinkedIn=geoId/city. Others (ats/indeed/naukri_v1) stay registered, off.
|
||||
BOARDS_ENABLED: str = "naukri,foundit,linkedin,indeed,workindia"
|
||||
BOARD_TIMEOUT_S: float = 55.0 # per-board cap — a slow/hung board is skipped, the search stays fast
|
||||
|
||||
# Warm pool ("the shelf") — bank surplus fetched jobs per (user, query) so we don't re-pay Apify for
|
||||
# a full sweep on every search. Serve from the pool; refill only when it dips. Reorder EARLY (top up
|
||||
# in the background) and hard-floor a blocking fetch so the pool never runs thin.
|
||||
POOL_ENABLED: bool = True
|
||||
POOL_REORDER_AT: int = 80 # fresh-unseen < this → background top-up (while still serving)
|
||||
POOL_SAFETY_FLOOR: int = 70 # fresh-unseen < this → BLOCK + fetch before serving (hard floor)
|
||||
POOL_TTL_HOURS: int = 72 # banked jobs older than this are stale (likely filled) → dropped
|
||||
POOL_MAX_PER_QUERY: int = 400 # cap the bank per (user, query) so it can't grow unbounded
|
||||
|
||||
# Per-board fetch budget (the #1 Apify cost lever). Demo-sized to conserve credits.
|
||||
NAUKRI_MAX_JOBS: int = 15 # blackfalcondata maxResults (no floor); fetchDetails=True for offsite apply links
|
||||
|
||||
@@ -37,6 +37,23 @@ class UserFeed(Base):
|
||||
)
|
||||
|
||||
|
||||
class UserJobPool(Base):
|
||||
"""The WARM POOL (the 'shelf'). Surplus fetched-but-not-yet-shown jobs banked per (user, query) so
|
||||
subsequent searches serve from here instead of re-paying Apify for a fresh sweep every time. One row
|
||||
per (user, query_sig, job). TTL'd by `pooled_at`; already-shown jobs are filtered on read via the
|
||||
seen-net. The gate reorders (background top-up) at REORDER fresh-unseen and hard-floors a blocking
|
||||
fetch below SAFETY_FLOOR, so the pool never runs thin."""
|
||||
__tablename__ = "user_job_pool"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
query_sig: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
job_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
job: Mapped[dict] = mapped_column(JSON, default=dict) # the normalized ScoutJob (pre-sift/curate)
|
||||
pooled_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class OpportunityState(Base):
|
||||
"""Per-(user, opportunity) state that SURVIVES deck refreshes. Persists: `applied`/`dismissed`
|
||||
status, the resume-builder document ids already generated (never re-pay to re-tailor), and the
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from app.db.models import Base, OpportunityState, UserFeed
|
||||
from app.db.models import Base, OpportunityState, UserFeed, UserJobPool
|
||||
from app.db.session import engine, session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -360,3 +360,84 @@ async def mark_seen(user_id: str, opportunity_ids: list[str]) -> None:
|
||||
await s.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("mark_seen failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
# ── Warm pool (the shelf) ────────────────────────────────────────────────────
|
||||
async def pool_save(user_id: str, query_sig: str, jobs: list[dict]) -> None:
|
||||
"""Bank fetched jobs into the per-(user, query) pool (upsert by job id; refresh pooled_at). Then
|
||||
sweep out anything older than the TTL so the pool stays fresh + bounded. No-op if the DB is off."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.config import get_settings
|
||||
factory = session_factory()
|
||||
if factory is None or not user_id or not jobs:
|
||||
return
|
||||
rows = [{"user_id": user_id, "query_sig": query_sig, "job_id": j["id"], "job": j}
|
||||
for j in jobs if j.get("id")]
|
||||
if not rows:
|
||||
return
|
||||
s = get_settings()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=s.POOL_TTL_HOURS)
|
||||
try:
|
||||
async with factory() as sess:
|
||||
stmt = pg_insert(UserJobPool).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[UserJobPool.user_id, UserJobPool.query_sig, UserJobPool.job_id],
|
||||
set_={"job": stmt.excluded.job, "pooled_at": func.now()},
|
||||
)
|
||||
await sess.execute(stmt)
|
||||
# TTL sweep for this (user, query) — drop stale postings (likely filled).
|
||||
await sess.execute(
|
||||
UserJobPool.__table__.delete().where(
|
||||
(UserJobPool.user_id == user_id)
|
||||
& (UserJobPool.query_sig == query_sig)
|
||||
& (UserJobPool.pooled_at < cutoff)
|
||||
)
|
||||
)
|
||||
await sess.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("pool_save failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
async def pool_load_fresh_unseen(user_id: str, query_sig: str, seen: set[str]) -> list[dict]:
|
||||
"""The banked jobs for this query that are still FRESH (within TTL) and UNSEEN (not already shown),
|
||||
freshest first. This is the count the reorder/floor gate reads. Empty list if the DB is off."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.config import get_settings
|
||||
factory = session_factory()
|
||||
if factory is None or not user_id:
|
||||
return []
|
||||
s = get_settings()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=s.POOL_TTL_HOURS)
|
||||
try:
|
||||
async with factory() as sess:
|
||||
rows = (await sess.execute(
|
||||
select(UserJobPool.job_id, UserJobPool.job)
|
||||
.where((UserJobPool.user_id == user_id)
|
||||
& (UserJobPool.query_sig == query_sig)
|
||||
& (UserJobPool.pooled_at >= cutoff))
|
||||
.order_by(UserJobPool.pooled_at.desc())
|
||||
.limit(s.POOL_MAX_PER_QUERY)
|
||||
)).all()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("pool_load failed (non-fatal): %s", e)
|
||||
return []
|
||||
return [r.job for r in rows if r.job_id not in seen]
|
||||
|
||||
|
||||
async def save_cursors(user_id: str, query_sig: str, cursors: dict) -> None:
|
||||
"""Persist advanced per-board cursors WITHOUT touching the saved feed — used by the background
|
||||
refill (which sweeps + banks but has no new deck to show). Upsert on the one-row-per-user feed."""
|
||||
factory = session_factory()
|
||||
if factory is None or not user_id:
|
||||
return
|
||||
try:
|
||||
async with factory() as s:
|
||||
stmt = pg_insert(UserFeed).values(user_id=user_id, query_sig=query_sig, cursors=cursors or {})
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[UserFeed.user_id],
|
||||
set_={"cursors": cursors or {}, "query_sig": query_sig},
|
||||
)
|
||||
await s.execute(stmt)
|
||||
await s.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("save_cursors failed (non-fatal): %s", e)
|
||||
|
||||
@@ -75,7 +75,9 @@ def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details:
|
||||
# Apify. Cross-deck dedup is the PER-USER seen-net in our DB. (The feed actor has no page/offset.)
|
||||
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
|
||||
return {
|
||||
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
|
||||
# Targeted keyword (seniority + industry + a top skill) → fetch RIGHT-level/industry/skill jobs,
|
||||
# not bare-title breadth. Text-targeting degrades gracefully (never zeroes); the pool gives breadth.
|
||||
"keyword": C.build_keyword(prefs, seniority=True, industry=True, skills=True),
|
||||
"location": city, # precise city, e.g. "New Delhi"
|
||||
"maxResults": max_jobs,
|
||||
"fetchDetails": fetch_details,
|
||||
@@ -97,7 +99,7 @@ def build_foundit_input(prefs: Prefs, *, results_wanted: int = 50) -> dict:
|
||||
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
|
||||
city = _FOUNDIT_CITY.get(city.lower(), city) # alias to Foundit's canonical name
|
||||
return {
|
||||
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
|
||||
"keyword": C.build_keyword(prefs, seniority=True, industry=True, skills=True), # targeted
|
||||
"location": city,
|
||||
"results_wanted": results_wanted,
|
||||
}
|
||||
@@ -109,8 +111,13 @@ def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "we
|
||||
# recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing
|
||||
# filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which
|
||||
# become ENGINE ranking later. City-level location + a wrong industryId zero the actor out.
|
||||
# jobTitles is a TITLE field — prepend the seniority band (real titles, e.g. "Senior Sales Manager")
|
||||
# to target right-level roles. Industry stays OUT of the title field (it's not part of a job title).
|
||||
_title = C.title_of(prefs)
|
||||
if C.seniority_band(prefs) in ("Senior", "Lead") and _title:
|
||||
_title = f"{C.seniority_band(prefs)} {_title}"
|
||||
out: dict[str, Any] = {
|
||||
"jobTitles": [C.title_of(prefs)],
|
||||
"jobTitles": [_title],
|
||||
"maxItems": max_items,
|
||||
"sortBy": "date",
|
||||
"page": page, # Start Page Number — advance per re-run for NEW jobs
|
||||
@@ -156,16 +163,15 @@ def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "we
|
||||
|
||||
# ── Indeed (blunt: what + where + country only; everything else folds in) ────
|
||||
def build_indeed_input(prefs: Prefs, *, max_items: int = 80) -> dict:
|
||||
# valig~indeed-jobs-scraper: title + location + country(lowercase ISO2) + limit. ~7s/run (vs misceres 52s).
|
||||
loc = next((l for l in C.parsed_locations(prefs) if l["city"] or l["country"]), {})
|
||||
country_name = loc.get("country") or "India"
|
||||
return {
|
||||
# Indeed has no work-mode/seniority/industry params → fold them into the query.
|
||||
"position": C.build_keyword(prefs, seniority=True, industry=True),
|
||||
# Indeed has no work-mode/seniority/industry params → fold them into the title query.
|
||||
"title": C.build_keyword(prefs, seniority=True, industry=True),
|
||||
"location": loc.get("city") or "",
|
||||
"country": C.COUNTRY_ISO2.get(country_name, "IN"),
|
||||
"maxItemsPerSearch": max_items,
|
||||
"followApplyRedirects": True, # → real offsite apply URL
|
||||
"saveOnlyUniqueItems": True,
|
||||
"country": C.COUNTRY_ISO2.get(country_name, "IN").lower(), # valig wants lowercase ("in")
|
||||
"limit": max_items,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,12 +102,23 @@ def years_of(prefs: Prefs) -> Optional[int]:
|
||||
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."""
|
||||
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):
|
||||
|
||||
@@ -11,59 +11,86 @@ import json
|
||||
import logging
|
||||
|
||||
from app.config import get_settings
|
||||
from app.engine import rubric as _rubric
|
||||
from app.engine.llm import curate_enabled, gateway_client
|
||||
from app.engine.schema import Growth, MatchDim, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM = """You are Scout's senior recruiter. You receive a candidate's PROFILE and a pre-sifted list of \
|
||||
JOBS (already narrowed to the right city and title by cheaper tools). Two jobs:
|
||||
# Two-stage curation, by capability + cost:
|
||||
# STAGE 1 (Opus, _SYSTEM_SCORE): the JUDGMENT — rate the rubric dimensions on evidence. Integrity-
|
||||
# critical, so it stays on Opus. Lean output (scores + terse notes, no prose) → tighter calibration
|
||||
# AND ~75% less Opus output cost.
|
||||
# STAGE 2 (Haiku, _SYSTEM_CARDS): the PROSE — write the report cards FROM Opus's scores+notes. Cheap
|
||||
# output ($5/M vs $75/M); Haiku never judges fit or picks a score, only writes.
|
||||
# The overall score is COMPUTED by the engine (rubric.aggregate) from Stage-1 ratings — never model-emitted.
|
||||
|
||||
1. CURATE — keep ONLY the jobs genuinely worth this candidate's time. Be honest: if only 8 of the jobs \
|
||||
are real matches, return 8. NEVER pad to a number. Drop weak, duplicate, or off-target roles.
|
||||
_SYSTEM_SCORE = (
|
||||
"You are Scout's senior recruiter scoring job fit. You receive a candidate PROFILE and a pre-sifted "
|
||||
"list of JOBS (already narrowed to the right city + title by cheaper tools). Do two things:\n\n"
|
||||
"1. CURATE — keep ONLY jobs genuinely worth this candidate's time. Be honest: if only 8 are real "
|
||||
"matches, return 8. NEVER pad, drop weak/duplicate/off-target roles. The OVERALL score is COMPUTED "
|
||||
"from your ratings — you do NOT choose it — so a job earns its place on merit, not a number you set.\n\n"
|
||||
"2. For each KEPT job, RATE these fixed rubric dimensions 0-100, EACH against its anchor, each with a "
|
||||
"SHORT evidence note (a few words) grounded in THIS job's real content. Rate every dimension that "
|
||||
'applies (omit "industry" only if the candidate states no industry preference). Never omit a hard '
|
||||
"dimension to flatter the score — you rate EVIDENCE, not an outcome.\n"
|
||||
+ _rubric.prompt_block() + "\n\n"
|
||||
"Rate against the ANCHORS honestly and use the FULL range: a same-function, same-city, same-level job "
|
||||
"where the candidate genuinely meets most requirements earns dimension scores in the 90s (so the "
|
||||
"computed overall is a confident 90s match — that hyper-relevance is the goal); a partial match earns "
|
||||
"less; a weak dimension MUST score low and drag the overall down. Don't inflate, don't undersell.\n\n"
|
||||
"NON-TECH roles (sales, finance, HR, ops, support, …): rate \"skills\" as the share of the job's stated "
|
||||
"RESPONSIBILITIES the candidate can do — NOT a tag checklist; a missing skill-tag list is not a negative.\n\n"
|
||||
'Also emit fit: "fit" (current-state match) or "stretch" (a genuine reach).\n\n'
|
||||
"Respond with ONLY a JSON object (no prose, no markdown fences):\n"
|
||||
'{"kept":[{"id","fit","dimensions":{"role":{"score","note"},"skills":{"score","note"},'
|
||||
'"seniority":{"score","note"},"location":{"score","note"},"industry":{"score","note"},'
|
||||
'"experience":{"score","note"}}}]}\n'
|
||||
'Use each job\'s exact "id".'
|
||||
)
|
||||
|
||||
2. For each KEPT job, write a REPORT CARD in this voice (modeled on a supportive video-coaching analysis):
|
||||
- archetype: a short, characterful label — e.g. "The Stretch Worth Taking", "The Safe Powerhouse", \
|
||||
"The Skill-Adjacent Pivot". Memorable, never generic.
|
||||
- one_line: ONE honest, balanced sentence — name the strength AND the gap. Never pure hype.
|
||||
- breakdown: 3-4 dimensions (e.g. Skill match, Experience, Location, Industry), each with an honest \
|
||||
0-100 score, a level ("Strong" >=72 / "Solid" 52-71 / "Light" <52), and a specific one-line note grounded \
|
||||
in THIS job's actual skills/title.
|
||||
- coach_note: one warm, actionable line — what to lead with, or what to shore up before applying.
|
||||
- growth (optional): {"text","from","to"} — the gap to close and the score lift closing it buys.
|
||||
- score: honest 0-100 overall fit. fit: "fit" (a current-state match) or "stretch" (a genuine reach).
|
||||
_SYSTEM_CARDS = (
|
||||
"You are Scout's encouraging career coach. For each scored job below you get its title, company, the "
|
||||
"candidate's situation, the overall fit score, and the recruiter's per-dimension EVIDENCE notes. Write "
|
||||
"a short REPORT CARD per job, grounded ONLY in those notes — invent nothing, never contradict a score.\n\n"
|
||||
"Voice: warm, specific, honest (supportive video-coaching). For each id, write:\n"
|
||||
' - archetype: a short characterful label — e.g. "The Stretch Worth Taking", "The Safe Powerhouse".\n'
|
||||
" - one_line: ONE balanced sentence — name the real strength AND the honest gap. Never pure hype.\n"
|
||||
" - coach_note: one actionable line — what to lead with, or what to shore up before applying.\n"
|
||||
' - growth (optional): {"text","from","to"} — text = the gap to close; from/to = NUMBERS, the score '
|
||||
"now and the realistic score after closing it (e.g. from 82 to 90).\n\n"
|
||||
"Respond with ONLY a JSON object (no prose/fences):\n"
|
||||
'{"cards":[{"id","archetype","one_line","coach_note","growth":{"text","from","to"}}]}\n'
|
||||
'Use each job\'s exact "id".'
|
||||
)
|
||||
|
||||
Honesty rules: scores reflect reality, on a calibrated scale. A genuinely strong CURRENT-STATE match \
|
||||
(role + seniority + location + most requirements align) is a real low-to-mid 80s; partial/stretch matches \
|
||||
sit in the 60s-70s; reserve high-80s/90s for a near-perfect fit. Don't inflate weak matches — but don't \
|
||||
under-sell strong ones into the 60s either. Ground every note in the job's real content; invent nothing.
|
||||
|
||||
NON-TECH roles (sales, marketing, finance, HR, operations, support, supply-chain, legal, admin): the SPINE \
|
||||
of the match is role/function fit + responsibility overlap + industry + seniority — NOT a skill-tag checklist. \
|
||||
These postings rarely list skills, and a missing skill list is NOT a negative signal — score on the \
|
||||
responsibilities and role alignment, never penalize the candidate for the board's empty skill field. A strong \
|
||||
role+industry+responsibility+seniority match with no listed tags is a genuine ~80 match, not a ~65. Make one \
|
||||
breakdown dimension "Role fit" (same function / adjacent / different).
|
||||
|
||||
Respond with ONLY a JSON object (no prose, no markdown fences):
|
||||
{"kept":[{"id","score","fit","archetype","one_line","breakdown":[{"name","score","level","note"}],\
|
||||
"coach_note","growth":{"text","from","to"}}]}
|
||||
Order "kept" best-first. Use each job's exact "id"."""
|
||||
def _compact(d: dict) -> dict:
|
||||
"""Drop None / empty values so the LLM payload stays lean (no null noise)."""
|
||||
return {k: v for k, v in d.items() if v not in (None, "", [], {})}
|
||||
|
||||
|
||||
def _profile_brief(prefs: dict, ctx: dict | None) -> dict:
|
||||
ctx = ctx or {}
|
||||
return {
|
||||
# Richer profile = the rubric can SEE that skills/experience are genuinely met (so those dimensions
|
||||
# score what they deserve, not a guess). All fields are free (already in the resume context).
|
||||
return _compact({
|
||||
"target_title": prefs.get("title"),
|
||||
"target_roles": prefs.get("role"),
|
||||
"target_location": prefs.get("location"),
|
||||
"seniority": prefs.get("experience"),
|
||||
"years": prefs.get("years"),
|
||||
"years": prefs.get("years") or ctx.get("years_experience"),
|
||||
"target_industry": prefs.get("industry"),
|
||||
"skills": ctx.get("skills"),
|
||||
"skills": (ctx.get("skills") or [])[:20],
|
||||
"current_role": ctx.get("current_role"),
|
||||
"current_company": ctx.get("current_company"),
|
||||
"past_titles": (ctx.get("past_titles") or ctx.get("titles") or [])[:4],
|
||||
"experience_summary": str(ctx.get("experience_summary") or ctx.get("summary") or "")[:600] or None,
|
||||
"education": ctx.get("education"),
|
||||
"industries_worked": ctx.get("industries"),
|
||||
"stretch_appetite": prefs.get("stretch", "balanced"),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
def _job_brief(j: dict) -> dict:
|
||||
@@ -77,8 +104,8 @@ def _job_brief(j: dict) -> dict:
|
||||
"industry": d.get("industry"),
|
||||
"seniority": j.get("seniority_level"),
|
||||
"pay": j.get("payLabel"),
|
||||
"skills": (d.get("skills") or j.get("required_skills") or [])[:12],
|
||||
"responsibilities": (d.get("description") or "")[:1200], # was 500 — Opus scored from a stub
|
||||
"skills": (d.get("skills") or j.get("required_skills") or [])[:10],
|
||||
"responsibilities": (d.get("description") or "")[:600], # enough for Opus to judge role-fit; keeps the payload lean
|
||||
"prelim_score": j.get("matchScore"),
|
||||
}
|
||||
|
||||
@@ -118,70 +145,131 @@ def _salvage_kept(text: str) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _to_match(k: dict) -> MatchResult:
|
||||
dims = [
|
||||
MatchDim(name=str(d["name"]), score=int(d["score"]), level=d["level"], note=d.get("note"))
|
||||
for d in (k.get("breakdown") or [])
|
||||
]
|
||||
g = k.get("growth")
|
||||
growth = Growth(text=g["text"], **{"from": int(g["from"]), "to": int(g["to"])}) if g else None
|
||||
def _scored_card(k: dict) -> dict | None:
|
||||
"""Stage-1 result → a partial card carrying the COMPUTED overall + the rubric breakdown (no prose yet).
|
||||
Returns None for a malformed entry (no dimensions). The engine computes the score — never the model."""
|
||||
raw = k.get("dimensions") or {}
|
||||
dim_scores = {key: float(raw[key]["score"]) for key in _rubric.KEYS
|
||||
if isinstance(raw.get(key), dict) and raw[key].get("score") is not None}
|
||||
if not dim_scores:
|
||||
return None
|
||||
overall = _rubric.aggregate(dim_scores)
|
||||
dims = [{"key": key, "name": _rubric.LABELS[key], "score": round(dim_scores[key]),
|
||||
"level": _rubric.level_for(dim_scores[key]),
|
||||
"note": (raw[key].get("note") if isinstance(raw.get(key), dict) else None)}
|
||||
for key in _rubric.KEYS if key in dim_scores]
|
||||
return {"id": k.get("id"), "overall": overall,
|
||||
"fit": k.get("fit") or ("fit" if overall >= 75 else "stretch"), "dims": dims}
|
||||
|
||||
|
||||
def _to_match(sc: dict, prose: dict | None) -> MatchResult:
|
||||
"""Combine a Stage-1 scored card (sc) with Stage-2 prose (or templated fallback) → the MatchResult."""
|
||||
prose = prose or {}
|
||||
dims = [MatchDim(name=d["name"], score=d["score"], level=d["level"], note=d.get("note")) for d in sc["dims"]]
|
||||
growth = None
|
||||
g = prose.get("growth")
|
||||
if g:
|
||||
try:
|
||||
growth = Growth(text=g["text"], **{"from": int(g["from"]), "to": int(g["to"])})
|
||||
except (KeyError, ValueError, TypeError):
|
||||
growth = None
|
||||
top = max(sc["dims"], key=lambda d: d["score"]) if sc["dims"] else None
|
||||
one_line = prose.get("one_line") or (f"Strong on {top['name'].lower()}." if top else "Curated by Scout.")
|
||||
return MatchResult(
|
||||
score=int(k["score"]),
|
||||
fit=k["fit"],
|
||||
archetype=k.get("archetype"),
|
||||
one_line=k.get("one_line"),
|
||||
reason=k.get("one_line") or k.get("archetype") or "Curated by Scout",
|
||||
breakdown=dims,
|
||||
growth=growth,
|
||||
coach_note=k.get("coach_note"),
|
||||
proofReady=any(d.level == "Strong" for d in dims),
|
||||
factors={},
|
||||
score=sc["overall"], fit=sc["fit"],
|
||||
archetype=prose.get("archetype") or ("The Strong Match" if sc["overall"] >= 80 else "Worth a Look"),
|
||||
one_line=one_line, reason=one_line,
|
||||
breakdown=dims, growth=growth, coach_note=prose.get("coach_note"),
|
||||
proofReady=any(d.level == "Strong" for d in dims), factors={},
|
||||
)
|
||||
|
||||
|
||||
def curate(prefs: dict, ctx: dict | None, sifted_jobs: list[dict]) -> list[dict] | None:
|
||||
"""Opus reads the sifted pool → the curated jobs (kept, best-first, with real report cards).
|
||||
`None` on any failure so the caller degrades to the sift + templated cards."""
|
||||
def _score_stage(prefs: dict, ctx: dict | None, sifted_jobs: list[dict], s) -> list[dict] | None:
|
||||
"""STAGE 1 (Opus): rate the rubric dimensions → scored cards (computed overall, ≥ floor, best-first).
|
||||
None on a hard failure (caller falls back to the sift). Empty list = Opus kept nothing strong."""
|
||||
client = gateway_client()
|
||||
if not (curate_enabled() and client and sifted_jobs):
|
||||
return None
|
||||
s = get_settings()
|
||||
payload = {"profile": _profile_brief(prefs, ctx), "jobs": [_job_brief(j) for j in sifted_jobs]}
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=s.CURATE_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
],
|
||||
max_tokens=8000, # was 4000 — 28 sifted jobs × full report cards overflowed → truncation
|
||||
messages=[{"role": "system", "content": _SYSTEM_SCORE},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
|
||||
max_tokens=4000, # scores + terse notes only (no prose) → far smaller than the old combined call
|
||||
timeout=120,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
except Exception as e: # noqa: BLE001 — API failure → fall back to the sift
|
||||
logger.warning("curate (Opus) call failed, falling back to sift: %s", e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("score stage (Opus) failed, falling back to sift: %s", e)
|
||||
return None
|
||||
# Robust parse: a single bad char / truncation must NOT drop the whole shortlist to the fallback.
|
||||
try:
|
||||
data = {"kept": _parse_json(content).get("kept") or []}
|
||||
kept = _parse_json(content).get("kept") or []
|
||||
except Exception: # noqa: BLE001
|
||||
salvaged = _salvage_kept(content)
|
||||
if not salvaged:
|
||||
logger.warning("curate JSON unparseable + nothing salvageable — falling back to sift")
|
||||
kept = _salvage_kept(content)
|
||||
if not kept:
|
||||
logger.warning("score stage unparseable — falling back to sift")
|
||||
return None
|
||||
logger.warning("curate JSON malformed; salvaged %d/%d kept items", len(salvaged), len(sifted_jobs))
|
||||
data = {"kept": salvaged}
|
||||
logger.warning("score stage JSON malformed; salvaged %d items", len(kept))
|
||||
# Floor check on the RAW computed score (weak never shown), THEN the transparent presentation
|
||||
# calibration (monotonic + floor-anchored, so ordering holds and weak never becomes strong).
|
||||
cards = [c for c in (_scored_card(k) for k in kept) if c and c["overall"] >= s.MATCH_FLOOR]
|
||||
if s.CALIBRATION_ENABLED:
|
||||
for c in cards:
|
||||
c["overall"] = _rubric.calibrate(c["overall"], floor=s.MATCH_FLOOR, gamma=s.CALIBRATION_GAMMA)
|
||||
cards.sort(key=lambda c: -c["overall"]) # best-first by the (calibrated) score
|
||||
return cards
|
||||
|
||||
|
||||
def _cards_stage(prefs: dict, ctx: dict | None, scored: list[dict], by_id: dict, s) -> dict:
|
||||
"""STAGE 2 (Haiku): write report-card prose FROM Opus's scores+notes. Cheap output, never judges.
|
||||
Returns {id: prose}. On any failure → {} so _to_match uses templated prose over the real scores."""
|
||||
client = gateway_client()
|
||||
if not client or not scored:
|
||||
return {}
|
||||
briefs = [{"id": c["id"], "title": (by_id.get(c["id"]) or {}).get("title"),
|
||||
"company": (by_id.get(c["id"]) or {}).get("organization"),
|
||||
"overall": c["overall"], "fit": c["fit"],
|
||||
"evidence": {d["key"]: d.get("note") for d in c["dims"]}} for c in scored]
|
||||
payload = {"profile": _profile_brief(prefs, ctx), "scored_jobs": briefs}
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=s.SUGGEST_MODEL, # Haiku — fast + ~15x cheaper output than Opus
|
||||
messages=[{"role": "system", "content": _SYSTEM_CARDS},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
|
||||
max_tokens=4000, timeout=60,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
except Exception as e: # noqa: BLE001 — prose is non-critical; degrade to templated cards
|
||||
logger.warning("cards stage (Haiku) failed, using templated prose: %s", e)
|
||||
return {}
|
||||
try:
|
||||
cards = _parse_json(content).get("cards") or []
|
||||
except Exception: # noqa: BLE001
|
||||
cards = _salvage_kept(content) # brace-scanner works for any [{…}] list
|
||||
return {c.get("id"): c for c in cards if c.get("id")}
|
||||
|
||||
|
||||
def curate(prefs: dict, ctx: dict | None, sifted_jobs: list[dict]) -> list[dict] | None:
|
||||
"""Two-stage curation: Opus SCORES the rubric (integrity) → Haiku WRITES the cards (cheap prose).
|
||||
`None` only on a Stage-1 failure (caller falls back to the sift + templated cards)."""
|
||||
s = get_settings()
|
||||
if not (curate_enabled() and gateway_client() and sifted_jobs):
|
||||
return None
|
||||
scored = _score_stage(prefs, ctx, sifted_jobs, s)
|
||||
if scored is None: # hard Opus failure → sift fallback
|
||||
return None
|
||||
by_id = {j["id"]: j for j in sifted_jobs}
|
||||
prose = _cards_stage(prefs, ctx, scored, by_id, s) # {} on failure → templated prose, scores intact
|
||||
out: list[dict] = []
|
||||
for k in data.get("kept") or []:
|
||||
job = by_id.get(k.get("id"))
|
||||
for c in scored:
|
||||
job = by_id.get(c["id"])
|
||||
if not job:
|
||||
continue
|
||||
try:
|
||||
m = _to_match(k)
|
||||
except Exception: # noqa: BLE001 — skip a malformed card, keep the rest
|
||||
m = _to_match(c, prose.get(c["id"]))
|
||||
if m.score < s.MATCH_FLOOR: # belt-and-suspenders (Stage 1 already floored)
|
||||
continue
|
||||
job["match"] = m.as_dict()
|
||||
job["matchScore"] = m.score
|
||||
out.append(job)
|
||||
return out or None
|
||||
# Opus succeeded (None only on hard failure) → honor its curation even if empty ("no strong matches"),
|
||||
# never fall back to UNFILTERED white-box cards that could surface a sub-floor match.
|
||||
return out
|
||||
|
||||
@@ -20,7 +20,7 @@ def _job_text(job: dict) -> str:
|
||||
d.get("role_category") or "",
|
||||
d.get("industry") or "",
|
||||
" ".join(d.get("skills") or job.get("required_skills") or []),
|
||||
(d.get("description") or "")[:2000],
|
||||
(d.get("description") or "")[:1000], # JD signal is front-loaded; 1k keeps embeds lean + cheap
|
||||
]
|
||||
return " · ".join(p for p in parts if p)
|
||||
|
||||
|
||||
@@ -199,47 +199,74 @@ def naukri_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None:
|
||||
if str(it.get("isExpired", "False")).lower() == "true":
|
||||
# Indeed (valig actor) leaves location.city empty — only lat/long. Map to the nearest Indian metro so
|
||||
# the card + the location factor have a city (the board already filtered to the searched city anyway).
|
||||
_INDIA_METROS = {
|
||||
"Mumbai": (19.076, 72.877), "Delhi": (28.61, 77.21), "Bengaluru": (12.97, 77.59),
|
||||
"Hyderabad": (17.385, 78.486), "Chennai": (13.083, 80.27), "Kolkata": (22.57, 88.36),
|
||||
"Pune": (18.52, 73.856), "Ahmedabad": (23.03, 72.58), "Gurugram": (28.46, 77.03),
|
||||
"Noida": (28.535, 77.39), "Jaipur": (26.91, 75.79), "Chandigarh": (30.73, 76.78),
|
||||
"Kochi": (9.93, 76.27), "Indore": (22.72, 75.86), "Coimbatore": (11.02, 76.96),
|
||||
}
|
||||
|
||||
|
||||
def _city_from_latlon(lat, lon) -> str | None:
|
||||
try:
|
||||
lat, lon = float(lat), float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
title = (it.get("positionName") or "").strip()
|
||||
org = (it.get("company") or "").strip()
|
||||
best, bestd = None, 9e9
|
||||
for name, (clat, clon) in _INDIA_METROS.items():
|
||||
d = (lat - clat) ** 2 + (lon - clon) ** 2
|
||||
if d < bestd:
|
||||
bestd, best = d, name
|
||||
return best if bestd < 1.5 else None # ~130km radius; farther → leave unknown, don't mislabel
|
||||
|
||||
|
||||
def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None:
|
||||
# valig~indeed-jobs-scraper schema (7s vs misceres's 52s): title, employer.name, description.text,
|
||||
# location{city/lat/long}, jobUrl (offsite employer site), url (Indeed listing), key, baseSalary.
|
||||
if it.get("expired"):
|
||||
return None
|
||||
title = (it.get("title") or "").strip()
|
||||
org = ((it.get("employer") or {}).get("name") or "").strip()
|
||||
if not title or not org:
|
||||
return None
|
||||
# Indeed location is "Area, City, State" (or "City, State") — the city is the 2nd-to-last part.
|
||||
parts = [p.strip() for p in (it.get("location") or "").split(",") if p.strip()]
|
||||
city = (parts[-2] if len(parts) >= 2 else (parts[0] if parts else None))
|
||||
types = []
|
||||
try:
|
||||
types = [t.strip() for t in eval(it.get("jobType") or "[]")] if it.get("jobType") else []
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sal = it.get("salary") or ""
|
||||
lpa, pay = _salary_from_amount(_digits(sal), per_year="year" in sal.lower()) if sal else (None, "Not disclosed")
|
||||
ext = it.get("externalApplyLink")
|
||||
offsite = bool(ext and ext != "None") # externalApplyLink = real offsite employer link
|
||||
apply_url = ext if offsite else it.get("url") # offsite first, Indeed listing fallback
|
||||
mode = "remote" if any("remote" in t.lower() for t in types) else None
|
||||
# jobType is employment classification ("Full-time", "Remote") — NOT skills. The real signal for
|
||||
# embedding + the curator is the description, which the actor DOES return (verified Phase 0).
|
||||
loc = it.get("location") or {}
|
||||
city = (loc.get("city") or "").strip() or _city_from_latlon(loc.get("latitude"), loc.get("longitude"))
|
||||
country = loc.get("countryName") or "India"
|
||||
job_url = it.get("jobUrl") or ""
|
||||
offsite = bool(job_url and "indeed.com" not in job_url) # jobUrl = real offsite employer link
|
||||
apply_url = job_url if offsite else it.get("url") # offsite first, Indeed listing fallback
|
||||
bs = it.get("baseSalary") or {}
|
||||
lpa, pay = None, "Not disclosed"
|
||||
amt = bs.get("max") or bs.get("min")
|
||||
if amt and bs.get("currencyCode") in (None, "INR"):
|
||||
mult = {"YEAR": 1, "MONTH": 12, "WEEK": 52, "DAY": 260, "HOUR": 2080}.get(bs.get("unitOfWork"), 1)
|
||||
lpa = round(amt * mult / 100000, 1)
|
||||
pay = f"₹{lpa:g} LPA"
|
||||
etypes = [str(v) for v in (it.get("jobTypes") or {}).values()]
|
||||
occ = [str(v) for v in (it.get("occupations") or {}).values()]
|
||||
mode = "remote" if any("remote" in t.lower() for t in etypes) else None
|
||||
details = _compact({
|
||||
"description": _plain(it.get("description") or it.get("descriptionHTML")),
|
||||
"employment_type": ", ".join(types) or None,
|
||||
"description": _plain((it.get("description") or {}).get("text")),
|
||||
"employment_type": ", ".join(etypes) or None,
|
||||
"role_category": occ[0] if occ else None,
|
||||
})
|
||||
return {
|
||||
"id": f"indeed-{it.get('id')}",
|
||||
"id": f"indeed-{it.get('key')}",
|
||||
"title": title,
|
||||
"organization": org,
|
||||
"logo": _logo(org, _logo_url(it)),
|
||||
"posted_date": _posted_date(it),
|
||||
"logo": _logo(org),
|
||||
"posted_date": (it.get("datePublished") or it.get("dateOnIndeed") or None),
|
||||
"location_city": city,
|
||||
"location_country": "India",
|
||||
"location_country": country,
|
||||
"location_mode": mode,
|
||||
"matchScore": 0,
|
||||
"salary_lpa": lpa,
|
||||
"payLabel": pay,
|
||||
"required_skills": [], # Indeed exposes no skill list; description carries the signal
|
||||
"tags": types[:3],
|
||||
"tags": etypes[:3],
|
||||
"applicants": None,
|
||||
"note": f"Live from {board}",
|
||||
"apply_url": apply_url,
|
||||
|
||||
66
app/engine/rubric.py
Normal file
66
app/engine/rubric.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""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 & requirements", "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)
|
||||
@@ -21,10 +21,23 @@ def _ats_companies() -> list[dict]:
|
||||
return [{"company": c.strip()} for c in get_settings().ATS_COMPANIES.split(",") if c.strip()]
|
||||
|
||||
|
||||
def pool_decision(n_fresh: int, force_fresh: bool, floor: int, reorder: int) -> str:
|
||||
"""The warm-pool gate. Given how many FRESH, UNSEEN jobs are banked, decide where candidates come from:
|
||||
'sweep' — blocking Apify fetch (pool below the hard floor, or a forced fresh search)
|
||||
'pool+refill' — serve from the pool NOW + top it up in the BACKGROUND (dipped below the reorder mark)
|
||||
'pool' — serve from the pool, no fetch at all (pool healthy)
|
||||
Reorder EARLY and floor HIGH so the pool never runs thin (floor ≤ reorder)."""
|
||||
if force_fresh or n_fresh < floor:
|
||||
return "sweep"
|
||||
if n_fresh < reorder:
|
||||
return "pool+refill"
|
||||
return "pool"
|
||||
|
||||
|
||||
# Boards whose actor takes a real page/offset → they get a per-board cursor that advances each run.
|
||||
# Everyone else (Naukri/Foundit feeds — no page param) relies purely on the per-user seen-net; we do
|
||||
# NOT track a meaningless cursor for them.
|
||||
PAGINATING_BOARDS = {"linkedin", "indeed"}
|
||||
PAGINATING_BOARDS = {"linkedin"} # valig-indeed uses `limit` (no page) → seen-net, not a cursor
|
||||
|
||||
# board key → (actor_id, build_input, normalizer). "Balanced" India stack = naukri+foundit+linkedin,
|
||||
# all city-filtered at the board. The rest stay registered (code ready) but off unless enabled.
|
||||
@@ -49,7 +62,7 @@ BOARDS = {
|
||||
"naukri_v1": ("muhammetakkurtt~naukri-job-scraper",
|
||||
lambda p: A.build_naukri_input(p, recall=True, max_jobs=get_settings().NAUKRI_MAX_JOBS),
|
||||
N.naukri_to_scoutjob),
|
||||
"indeed": ("misceres~indeed-scraper",
|
||||
"indeed": ("valig~indeed-jobs-scraper", # ~7s vs misceres ~52s; richer fields + offsite jobUrl
|
||||
lambda p: A.build_indeed_input(p, max_items=get_settings().INDEED_MAX_JOBS),
|
||||
N.indeed_to_scoutjob),
|
||||
"wellfound": ("blackfalcondata~wellfound-scraper", # registered, OFF — US-startup-heavy, not the India fit
|
||||
@@ -81,7 +94,17 @@ async def run_sweep(prefs: dict, *, fresh: bool = False) -> dict:
|
||||
# otherwise honor the configured cache mode (dev replays cached results for $0).
|
||||
cache_mode = "refresh" if fresh else None
|
||||
enabled = [b.strip() for b in get_settings().BOARDS_ENABLED.split(",") if b.strip() in BOARDS]
|
||||
results = await asyncio.gather(*(_fetch_board(k, prefs, cache_mode) for k in enabled), return_exceptions=True)
|
||||
cap = get_settings().BOARD_TIMEOUT_S
|
||||
|
||||
async def _timed(k: str):
|
||||
# A single slow/hung board must NOT hold the whole search hostage — cap it and degrade.
|
||||
try:
|
||||
return await asyncio.wait_for(_fetch_board(k, prefs, cache_mode), timeout=cap)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("board %s exceeded %ss — skipped (search stays fast)", k, cap)
|
||||
return k, []
|
||||
|
||||
results = await asyncio.gather(*(_timed(k) for k in enabled), return_exceptions=True)
|
||||
|
||||
merged: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
@@ -8,18 +8,50 @@ def test_parse_json_strips_fences_and_prose():
|
||||
assert C._parse_json('here: {"kept":[{"id":"1"}]} thanks')["kept"][0]["id"] == "1"
|
||||
|
||||
|
||||
def test_to_match_builds_full_contract():
|
||||
m = C._to_match({
|
||||
"id": "1", "score": 78, "fit": "fit", "archetype": "The Fit", "one_line": "strong but a gap",
|
||||
"breakdown": [{"name": "Skill match", "score": 80, "level": "Strong", "note": "real SQL"}],
|
||||
"coach_note": "lead with X", "growth": {"text": "close Y", "from": 78, "to": 85},
|
||||
def test_scored_card_computes_overall_from_rubric():
|
||||
# Stage 1: the curator RATES dimensions; _scored_card COMPUTES the overall (no model-picked score)
|
||||
from app.engine import rubric as R
|
||||
sc = C._scored_card({
|
||||
"id": "1", "fit": "fit",
|
||||
"dimensions": {
|
||||
"role": {"score": 100, "note": "same function"},
|
||||
"skills": {"score": 80, "note": "real SQL"},
|
||||
"seniority": {"score": 90, "note": "right level"},
|
||||
"location": {"score": 100, "note": "target city"},
|
||||
"industry": {"score": 85, "note": "adjacent"},
|
||||
"experience": {"score": 80, "note": "aligned"},
|
||||
},
|
||||
})
|
||||
d = m.as_dict()
|
||||
assert d["score"] == 78 and d["archetype"] == "The Fit" and d["fit"] == "fit"
|
||||
assert d["breakdown"][0]["note"] == "real SQL" and d["coach_note"] == "lead with X"
|
||||
expected = R.aggregate({"role": 100, "skills": 80, "seniority": 90,
|
||||
"location": 100, "industry": 85, "experience": 80})
|
||||
assert sc["overall"] == expected and sc["fit"] == "fit"
|
||||
assert C._scored_card({"id": "x", "dimensions": {}}) is None # no dims → dropped
|
||||
|
||||
|
||||
def test_to_match_merges_score_and_prose():
|
||||
# Stage 1 scored card + Stage 2 prose → the full MatchResult contract
|
||||
sc = C._scored_card({
|
||||
"id": "1", "fit": "fit",
|
||||
"dimensions": {"role": {"score": 100, "note": "same function"}, "skills": {"score": 80, "note": "real SQL"},
|
||||
"seniority": {"score": 90}, "location": {"score": 100}, "experience": {"score": 80}},
|
||||
})
|
||||
prose = {"archetype": "The Fit", "one_line": "strong but a gap", "coach_note": "lead with X",
|
||||
"growth": {"text": "close Y", "from": 78, "to": 85}}
|
||||
d = C._to_match(sc, prose).as_dict()
|
||||
assert d["score"] == sc["overall"] and d["archetype"] == "The Fit" and d["coach_note"] == "lead with X"
|
||||
assert any(dim["note"] == "real SQL" for dim in d["breakdown"])
|
||||
assert d["growth"]["from"] == 78 and d["growth"]["to"] == 85 and d["proofReady"] is True
|
||||
|
||||
|
||||
def test_to_match_falls_back_to_templated_prose():
|
||||
# Stage 2 failed (prose=None) → score + breakdown survive, prose is templated (never blank)
|
||||
sc = C._scored_card({"id": "1", "fit": "fit",
|
||||
"dimensions": {"role": {"score": 95}, "skills": {"score": 85}, "seniority": {"score": 90},
|
||||
"location": {"score": 100}, "experience": {"score": 80}}})
|
||||
d = C._to_match(sc, None).as_dict()
|
||||
assert d["score"] == sc["overall"] and d["archetype"] and d["one_line"] # templated, not empty
|
||||
|
||||
|
||||
def test_curate_disabled_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(C, "curate_enabled", lambda: False)
|
||||
assert C.curate({"title": "PM"}, None, [{"id": "1", "title": "PM"}]) is None
|
||||
|
||||
30
tests/test_pool.py
Normal file
30
tests/test_pool.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Warm-pool gate — the reorder/floor decision that keeps the shelf stocked without over-fetching."""
|
||||
from app.engine.search import pool_decision
|
||||
|
||||
FLOOR, REORDER = 70, 80 # the configured thresholds (POOL_SAFETY_FLOOR / POOL_REORDER_AT)
|
||||
|
||||
|
||||
def test_pool_healthy_serves_no_fetch():
|
||||
assert pool_decision(150, False, FLOOR, REORDER) == "pool"
|
||||
assert pool_decision(80, False, FLOOR, REORDER) == "pool" # exactly at reorder → still no fetch
|
||||
|
||||
|
||||
def test_pool_dipping_serves_plus_background_refill():
|
||||
assert pool_decision(79, False, FLOOR, REORDER) == "pool+refill" # below reorder → top up in bg
|
||||
assert pool_decision(70, False, FLOOR, REORDER) == "pool+refill" # at the floor → still serve, refill
|
||||
|
||||
|
||||
def test_pool_below_floor_blocks_and_fetches():
|
||||
assert pool_decision(69, False, FLOOR, REORDER) == "sweep" # under the hard floor → blocking fetch
|
||||
assert pool_decision(0, False, FLOOR, REORDER) == "sweep" # empty pool (first search) → fetch
|
||||
|
||||
|
||||
def test_forced_fresh_always_fetches():
|
||||
assert pool_decision(999, True, FLOOR, REORDER) == "sweep" # dev _fresh toggle bypasses the pool
|
||||
|
||||
|
||||
def test_floor_never_above_reorder_invariant():
|
||||
# the gate only makes sense when floor ≤ reorder (reorder early, floor as the hard backstop)
|
||||
from app.config import get_settings
|
||||
s = get_settings()
|
||||
assert s.POOL_SAFETY_FLOOR <= s.POOL_REORDER_AT
|
||||
65
tests/test_rubric.py
Normal file
65
tests/test_rubric.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""The honest rubric — the displayed score is COMPUTED from dimension ratings, never model-emitted."""
|
||||
import pytest
|
||||
|
||||
from app.engine import rubric as R
|
||||
|
||||
|
||||
def test_weights_sum_to_one():
|
||||
assert abs(sum(R.WEIGHTS.values()) - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_perfect_match_scores_high_90s():
|
||||
# all dimensions genuinely aligned → the overall lands in the high 90s (earned, not prompted)
|
||||
perfect = {k: 100 for k in R.KEYS}
|
||||
assert R.aggregate(perfect) == 100
|
||||
strong = {"role": 100, "skills": 92, "seniority": 95, "location": 100, "industry": 95, "experience": 90}
|
||||
assert R.aggregate(strong) >= 94 # a real strong match reaches the 90s
|
||||
|
||||
|
||||
def test_partial_match_scores_mid():
|
||||
partial = {"role": 75, "skills": 50, "seniority": 70, "location": 60, "industry": 65, "experience": 60}
|
||||
s = R.aggregate(partial)
|
||||
assert 55 <= s <= 72 # honestly a partial, not inflated
|
||||
|
||||
|
||||
def test_weak_role_drags_score_down():
|
||||
# role is 25% — a wrong-function job can't score high no matter how good the rest is
|
||||
weak = {"role": 15, "skills": 90, "seniority": 90, "location": 100, "industry": 90, "experience": 90}
|
||||
assert R.aggregate(weak) < 80
|
||||
|
||||
|
||||
def test_absent_dimension_redistributes_not_zeroes():
|
||||
# industry absent (no preference) must NOT act as a 0 that tanks the score — weight redistributes
|
||||
no_ind = {"role": 100, "skills": 100, "seniority": 100, "location": 100, "experience": 100}
|
||||
assert R.aggregate(no_ind) == 100 # not dragged down by the missing 10% industry weight
|
||||
|
||||
|
||||
def test_empty_is_zero_not_crash():
|
||||
assert R.aggregate({}) == 0
|
||||
|
||||
|
||||
def test_scores_are_clamped():
|
||||
assert R.aggregate({k: 150 for k in R.KEYS}) == 100
|
||||
assert R.aggregate({k: -20 for k in R.KEYS}) == 0
|
||||
|
||||
|
||||
def test_level_bands():
|
||||
assert R.level_for(90) == "Strong" and R.level_for(60) == "Solid" and R.level_for(40) == "Light"
|
||||
|
||||
|
||||
def test_prompt_block_lists_every_dimension():
|
||||
block = R.prompt_block()
|
||||
for k in R.KEYS:
|
||||
assert f'"{k}"' in block # every rubric key is shown to the curator
|
||||
|
||||
|
||||
def test_calibration_is_monotonic_floor_anchored_and_lifts_top():
|
||||
# the transparent "little bias": strictly increasing, floor fixed, top expanded into the 90s
|
||||
assert R.calibrate(50) == 50 # floor unchanged — weak stays weak
|
||||
assert R.calibrate(49) == 49 and R.calibrate(20) == 20 # below floor untouched
|
||||
assert R.calibrate(100) == 100 # ceiling fixed
|
||||
assert R.calibrate(90) >= 93 # genuinely-strong → presents in the 90s
|
||||
assert R.calibrate(60) <= 65 # a weak match is NOT lifted into the strong zone
|
||||
# strictly monotonic across the range (ordering never lies)
|
||||
vals = [R.calibrate(x) for x in range(50, 101, 5)]
|
||||
assert all(b > a for a, b in zip(vals, vals[1:]))
|
||||
Reference in New Issue
Block a user