Files
matchmaking-v2/app/engine/curate.py
raulgupta b1e9cdd182 Engine v2: embeddings sift + Opus curator + Postgres feed persistence
- 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/
2026-06-19 15:51:16 +05:30

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