diff --git a/app/agent/session.py b/app/agent/session.py index 37b32f0..4939c3f 100644 --- a/app/agent/session.py +++ b/app/agent/session.py @@ -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 diff --git a/app/config.py b/app/config.py index 5af67bf..08e8a61 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/db/models.py b/app/db/models.py index 032b4d8..dfeee15 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -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 diff --git a/app/db/repo.py b/app/db/repo.py index 3003e5f..9ecf80b 100644 --- a/app/db/repo.py +++ b/app/db/repo.py @@ -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) diff --git a/app/engine/board_adapters/adapters.py b/app/engine/board_adapters/adapters.py index 3215da2..b9dd6fe 100644 --- a/app/engine/board_adapters/adapters.py +++ b/app/engine/board_adapters/adapters.py @@ -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, } diff --git a/app/engine/board_adapters/coerce.py b/app/engine/board_adapters/coerce.py index 4d8a34d..1bade60 100644 --- a/app/engine/board_adapters/coerce.py +++ b/app/engine/board_adapters/coerce.py @@ -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): diff --git a/app/engine/curate.py b/app/engine/curate.py index cab4ee1..e9a7f28 100644 --- a/app/engine/curate.py +++ b/app/engine/curate.py @@ -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 diff --git a/app/engine/embed.py b/app/engine/embed.py index 977490e..237cbfc 100644 --- a/app/engine/embed.py +++ b/app/engine/embed.py @@ -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) diff --git a/app/engine/normalize.py b/app/engine/normalize.py index 20d09b0..a9d2ac0 100644 --- a/app/engine/normalize.py +++ b/app/engine/normalize.py @@ -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, diff --git a/app/engine/rubric.py b/app/engine/rubric.py new file mode 100644 index 0000000..8410514 --- /dev/null +++ b/app/engine/rubric.py @@ -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) diff --git a/app/engine/search.py b/app/engine/search.py index f768dff..644f08b 100644 --- a/app/engine/search.py +++ b/app/engine/search.py @@ -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() diff --git a/tests/test_curate.py b/tests/test_curate.py index b7c7e5a..b430b30 100644 --- a/tests/test_curate.py +++ b/tests/test_curate.py @@ -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 diff --git a/tests/test_pool.py b/tests/test_pool.py new file mode 100644 index 0000000..2536fdd --- /dev/null +++ b/tests/test_pool.py @@ -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 diff --git a/tests/test_rubric.py b/tests/test_rubric.py new file mode 100644 index 0000000..0aedcc1 --- /dev/null +++ b/tests/test_rubric.py @@ -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:]))