"""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. 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(), "skills": (d.get("skills") or [])[:12], "snippet": (d.get("description") or "")[:500], "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