Files
matchmaking-v2/app/engine/curate.py
raulgupta 2b2d67b4c8 Engine Phase 4: curate robustness + calibration + seniority inference
The measurement showed the USER-FACING Opus score is already well-calibrated — the real bug was curate
FAILING and dropping to the bad white-box fallback:
- curate.py: _salvage_kept() brace-scans + parses each kept object independently, so one bad char or a
  max-tokens truncation no longer drops the whole shortlist. max_tokens 4000 → 8000 (28 sifted jobs ×
  full report cards overflowed). This fixed the sales case (was failing → fallback).
- curate.py: calibration nudge in the prompt — a genuinely strong current-state match is a real low-80s,
  partial/stretch 60s-70s; don't under-sell strong matches into the 60s (without inflating weak ones).
- normalize.py + search.py: _seniority_from_title() infers seniority from the title (conservative) when a
  board omits it, applied centrally in the sweep → the experience factor + Opus brief aren't blind.

Verified (live Opus on the regression cases): tech MAE 9.8→4.5, sales FAILED→MAE 5.3 (kept 13),
ops 4.2→3.3 — user-facing scores now MAE 3-5 with zero curate failures. 31 tests pass.
2026-06-25 16:30:06 +05:30

188 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.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:
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.
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).
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 _profile_brief(prefs: dict, ctx: dict | None) -> dict:
ctx = ctx or {}
return {
"target_title": prefs.get("title"),
"target_roles": prefs.get("role"),
"target_location": prefs.get("location"),
"seniority": prefs.get("experience"),
"years": prefs.get("years"),
"target_industry": prefs.get("industry"),
"skills": ctx.get("skills"),
"current_role": ctx.get("current_role"),
"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 [])[:12],
"responsibilities": (d.get("description") or "")[:1200], # was 500 — Opus scored from a stub
"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 _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
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={},
)
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."""
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
)
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)
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 []}
except Exception: # noqa: BLE001
salvaged = _salvage_kept(content)
if not salvaged:
logger.warning("curate JSON unparseable + nothing salvageable — falling back to sift")
return None
logger.warning("curate JSON malformed; salvaged %d/%d kept items", len(salvaged), len(sifted_jobs))
data = {"kept": salvaged}
by_id = {j["id"]: j for j in sifted_jobs}
out: list[dict] = []
for k in data.get("kept") or []:
job = by_id.get(k.get("id"))
if not job:
continue
try:
m = _to_match(k)
except Exception: # noqa: BLE001 — skip a malformed card, keep the rest
continue
job["match"] = m.as_dict()
job["matchScore"] = m.score
out.append(job)
return out or None