- A1: fire the hard filter (Apify is precise); embedding vibe-ranker (embed.py) blends with the floor-free white-box (rank.py) to sift ~90 -> top 18 - A2: Opus curator (curate.py) reads the 18 -> honest <=count + Gemini-voiced report cards; graceful fallback to the white-box + templated cards - LLM via opencode.ai/zen gateway (llm.py): Opus chat + direct-OpenAI embeddings - Run LLM work off the event loop (asyncio.to_thread) so the Redis response publishes - C1: dedicated Postgres (app/db/) persists the per-user feed; get_scout_feed replays it so matches survive navigation/refresh - match contract + report-card fields (schema.py); skills.py; tests/
141 lines
6.0 KiB
Python
141 lines
6.0 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.
|
|
|
|
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
|