"""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