Files
matchmaking-v2/app/engine/curate.py
raulgupta 7c2640a999 Engine: honest high-90s scoring + warm pool + 2-stage curation (cost-down)
A search-quality + cost epic. Headline: genuine high-90s matches (88→96 on a senior
fintech-sales test), honestly, with no added spend.

SCORING — honest, computed, calibrated:
- rubric.py (NEW): the published rubric — 6 weighted dimensions + anchors. The overall is COMPUTED
  (rubric.aggregate), never model-emitted. A genuinely-aligned match arithmetically reaches the 90s.
- curate.py: TWO-STAGE — Opus SCORES the rubric dimensions (integrity-critical judgment), Haiku WRITES
  the report-card prose from Opus's evidence notes (cheap output, never judges). ~25% cheaper Opus +
  tighter calibration + better latency. Robust salvage parse; growth parse tolerant.
- rubric.calibrate: transparent presentation curve on the headline score — MONOTONIC, FLOOR-ANCHORED,
  UNIFORM (50→50, 70→74, 90→94, 95→97). A match% is a calibrated judgment; weak NEVER becomes strong,
  the breakdown stays raw evidence. Gated by CALIBRATION_ENABLED/GAMMA.
- MATCH_FLOOR=50 hard filter; floor checked on the RAW score before calibration.

RETRIEVAL — righter jobs (the honest score-lifter), cost-neutral:
- build_keyword(seniority, industry, skills): the recall boards (naukri-feed, foundit) + LinkedIn title
  now target right-level/industry/skill jobs instead of bare-title breadth → they align on more rubric
  dimensions → honestly higher scores. Verified live: no over-narrowing (138 jobs fetched, unchanged).
- Richer _profile_brief (resume skills/experience/education) so the rubric SEES requirements are met.

WARM POOL — stop re-paying Apify every search:
- UserJobPool: bank surplus fetched jobs per (user,query); serve from the pool, sweep only when fresh-
  unseen dips. Gate reorders at 80 / hard-floors at 70; background refill. (Saves Apify, not Opus.)

ACTORS / LATENCY (earlier in the epic):
- Indeed misceres(52s)→valig(7s); lean LLM payloads; per-board timeout. 48 tests pass.
2026-06-26 12:48:06 +05:30

276 lines
14 KiB
Python

"""The senior recruiter — Opus curates the sifted ~18 into the honest shortlist and writes the report
cards in the interview-service's Gemini video-analysis voice. **Opus's call is final.**
One gateway call: profile + the ~18 sifted jobs → strict JSON of the KEPT jobs (honest count ≤ the
pool — fewer when warranted, never padded), each carrying the full report-card contract. Returns `None`
on any failure (no key / bad JSON / API error) so the caller falls back to the sift + templated cards.
"""
from __future__ import annotations
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__)
# 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.
_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".'
)
_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".'
)
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 {}
# 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") or ctx.get("years_experience"),
"target_industry": prefs.get("industry"),
"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:
d = j.get("details") or {}
return {
"id": j["id"],
"title": j.get("title"),
"company": j.get("organization"),
"location": f"{j.get('location_city') or ''} {j.get('location_country') or ''}".strip(),
"role_category": d.get("role_category"),
"industry": d.get("industry"),
"seniority": j.get("seniority_level"),
"pay": j.get("payLabel"),
"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"),
}
def _parse_json(text: str) -> dict:
"""Tolerant parse — strip markdown fences / surrounding prose if the model added any."""
t = (text or "").strip()
if t.startswith("```"):
t = t.split("```", 2)[1].removeprefix("json").strip() if "```" in t[3:] else t.strip("`")
i, j = t.find("{"), t.rfind("}")
if i != -1 and j != -1:
t = t[i : j + 1]
return json.loads(t)
def _salvage_kept(text: str) -> list[dict]:
"""Recover valid kept-job objects from a malformed/truncated Opus JSON: brace-scan each top-level
{...} after "kept" and json.loads it on its own, skipping the one broken/cut-off object. Stops a
single bad character (or a max-tokens truncation) from dropping the WHOLE shortlist to the fallback."""
i = text.find('"kept"')
s = text[i:] if i != -1 else text
out: list[dict] = []
depth, start = 0, None
for idx, ch in enumerate(s):
if ch == "{":
if depth == 0:
start = idx
depth += 1
elif ch == "}" and depth:
depth -= 1
if depth == 0 and start is not None:
try:
out.append(json.loads(s[start : idx + 1]))
except Exception: # noqa: BLE001 — skip the broken object, keep the rest
pass
start = None
return out
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=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 _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()
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_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
logger.warning("score stage (Opus) failed, falling back to sift: %s", e)
return None
try:
kept = _parse_json(content).get("kept") or []
except Exception: # noqa: BLE001
kept = _salvage_kept(content)
if not kept:
logger.warning("score stage unparseable — falling back to sift")
return None
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 c in scored:
job = by_id.get(c["id"])
if not job:
continue
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)
# 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