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:
raulgupta
2026-06-26 12:48:06 +05:30
parent 2b2d67b4c8
commit 7c2640a999
14 changed files with 653 additions and 160 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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)

View File

@@ -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()

View File

@@ -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
View 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
View 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:]))