Files
matchmaking-v2/app/engine/suggest.py
raulgupta ecf5060acd Scout bubbling engine (Fine-tune pathway finder) + bundled engine fixes
- suggest.py: stage-aware engine (broad -> narrow -> role) — LLM (claude-haiku-4-5)
  generates bubbles per stage from the picks so far. Profile-grounded, generalizes
  to any field (English professor -> Senior Instructional Designer, etc.); None on
  no input -> frontend keeps seeded bubbles
- handle_suggest_bubbles action (run off the event loop) + SUGGEST_MODEL config
- bundled: LLM work (sift/curate/suggest) now via asyncio.to_thread so the Redis
  response can publish (fixes the loader hang); get_feed handler -> get_scout_feed
  (avoids the course-service action collision)
2026-06-19 19:25:24 +05:30

91 lines
4.0 KiB
Python

"""The bubbling engine — the Fine-tune "pathway finder".
One job: walk a user **broad → narrow → role** via three bubbler stages, generating each stage from
the user's PICKS so far (so it genuinely narrows). Same journey for everyone — a résumé just warms up
the first stage; a no-résumé user starts broad and funnels to the same concrete role.
stage "role" → BROAD role areas / domains (input: profile, if any)
stage "industry" → industries that fit the liked areas (input: role picks + profile)
stage "relevant" → SPECIFIC pursuable job titles (input: role + industry picks + profile)
Returns a list of short labels, or `None` on any failure / no usable input (the frontend then keeps
its seeded bubbles). Uses a fast/cheap model (latency-sensitive UI).
"""
from __future__ import annotations
import json
import logging
from app.config import get_settings
from app.engine.curate import _parse_json # tolerant JSON parser
from app.engine.llm import curate_enabled, gateway_client
logger = logging.getLogger(__name__)
_STAGE = {
"role": (
'Suggest BROAD role areas / domains a job-seeker could explore — wide buckets, NOT specific job '
'titles (e.g. "Data & AI", "Platform Engineering", "Product", "Growth"). Tailor to their role and '
'skills if given; otherwise span common tech + business areas so anyone can start broad.'
),
"industry": (
'The job-seeker is drawn to these role areas: {role}. Suggest INDUSTRIES / domains that fit those '
'areas and their profile (e.g. "AI/ML", "Fintech", "Cloud Infrastructure", "Healthcare").'
),
"relevant": (
'The job-seeker likes these role areas: {role}; and these industries: {industry}. Suggest SPECIFIC, '
'pursuable JOB TITLES (with seniority, e.g. "Senior ML Engineer", "Staff Data Engineer") they could '
'realistically target. These are the payoff of the funnel — concrete roles to pursue.'
),
}
_COUNT = {"role": 8, "industry": 7, "relevant": 10}
_COMMON = (
'\nRespond with ONLY a JSON object {{"items":[...]}} — short labels (max ~4 words each), best-fit '
'first, deduplicated, no prose.'
)
def _profile_brief(profile: dict) -> dict:
return {
"current_role": profile.get("current_role") or profile.get("title"),
"seniority": profile.get("seniority") or profile.get("experience"),
"years": profile.get("years"),
"industry": profile.get("industry"),
"skills": (profile.get("skills") or [])[:20],
}
def suggest(stage: str, picks: dict | None, profile: dict | None) -> list[str] | None:
client = gateway_client()
if not (curate_enabled() and client) or stage not in _STAGE:
return None
picks = picks or {}
role_picks = ", ".join(picks.get("role") or []) or "(none yet)"
ind_picks = ", ".join(picks.get("industry") or []) or "(none yet)"
brief = _profile_brief(profile or {})
# "relevant" (the role payoff) needs SOMETHING to go on — picks or a profile; else bail to seeded.
if stage == "relevant" and not (picks.get("role") or picks.get("industry") or brief.get("current_role")):
return None
system = _STAGE[stage].format(role=role_picks, industry=ind_picks) + _COMMON
try:
resp = client.chat.completions.create(
model=get_settings().SUGGEST_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": json.dumps({"profile": brief, "picks": picks}, ensure_ascii=False)},
],
max_tokens=400,
)
data = _parse_json(resp.choices[0].message.content)
except Exception as e: # noqa: BLE001 — frontend keeps its seeded fallback
logger.warning("suggest(%s) failed: %s", stage, e)
return None
out, seen = [], set()
for x in data.get("items") or []:
label = str(x).strip()
if label and label.lower() not in seen:
seen.add(label.lower())
out.append(label)
return out[: _COUNT[stage]] or None