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.
188 lines
8.6 KiB
Python
188 lines
8.6 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, 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
|