- 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)
91 lines
4.0 KiB
Python
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
|