- 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.
152 lines
6.8 KiB
Python
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
|