Files
matchmaking-v2/app/engine/curate.py
raulgupta 7c5a88dd3f Engine Phase 3: non-tech scoring (role-aware weights + curator) + vocab
- rank.py: get_weights(profile) — when the profile reads non-tech (sales/finance/HR/ops/support/…),
  tilt the weights to role(0.16)/industry(0.12)/semantic(0.12), drop skill(0.18). Tech weights
  unchanged. (The competency-skill-backfill was tried + REVERTED — uniform per-role competencies
  added no discrimination and Phase 2's real-cosine f_semantic already supplies the non-tech signal.)
- curate.py: non-tech instruction — role/responsibility fit is the spine; a missing skill list is NOT
  a negative; a strong role+industry+responsibility+seniority match is a genuine ~80, not ~65.
- test_contracts.py: allowlist the new keyword-handled non-tech vocab (active boards keyword-fold;
  numeric codes deferred since the active feed/recall paths don't use them).

Regression (120 pairs): MAE 18.4 → 16.1 (all from non-tech: sales 17.1→13.0, ops 16.3→11.7), recall
holds 0.92, tech unchanged. 31 tests pass.
2026-06-25 16:03:20 +05:30

152 lines
6.8 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.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 — a partial match is in the 60s-70s, not the 90s. Ground every note \
in the job's real content; invent nothing. Frame as helpful guidance, not a hiring verdict.
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 _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=4000,
)
data = _parse_json(resp.choices[0].message.content)
except Exception as e: # noqa: BLE001 — any failure → fall back to the sift
logger.warning("curate (Opus) failed, falling back to sift: %s", e)
return None
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