diff --git a/app/agent/session.py b/app/agent/session.py index 0707c90..59995de 100644 --- a/app/agent/session.py +++ b/app/agent/session.py @@ -42,6 +42,7 @@ class MatchmakingAgentSession: "get_opportunity_detail": self.handle_get_opportunity_detail, # new in v2 "run_search": self.handle_run_search, + "suggest_bubbles": self.handle_suggest_bubbles, # the Fine-tune "pathway finder" engine "tailor_resume": self.handle_tailor_resume, "submit_application": self.handle_submit_application, "get_apply_proof": self.handle_get_apply_proof, @@ -112,6 +113,16 @@ class MatchmakingAgentSession: clean_prefs = {k: v for k, v in prefs.items() if k not in ("user_context", "_fresh")} await save_feed(self.user_id, clean_prefs, result) + async def handle_suggest_bubbles(self, params: dict): + """Fine-tune bubbling — stage-aware (broad→narrow→role) bubbles from the picks so far.""" + from app.engine import suggest + stage = params.get("stage", "role") + items = await asyncio.to_thread( + suggest.suggest, stage, params.get("picks") or {}, params.get("profile") or {} + ) + await self.push("agent_data", action="bubbles_suggested", + data={"stage": stage, "items": items or []}) + async def handle_tailor_resume(self, params: dict): await self._stub("resume_tailored", "resume-builder: tailor resume to the chosen role") diff --git a/app/config.py b/app/config.py index 46f0da2..b9c5010 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,7 @@ class Settings(BaseSettings): DSPY_API_BASE: str | None = None DSPY_API_KEY: str | None = None CURATE_MODEL: str = "claude-opus-4-8" # the senior recruiter (Opus, latest) — Opus's call is final + SUGGEST_MODEL: str = "claude-haiku-4-5" # the bubbling engine — fast + cheap (latency-sensitive UI) EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI — gateway has no embeddings route) ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net) SIFT_TOP_K: int = 18 # candidate pool the curator reads diff --git a/app/engine/suggest.py b/app/engine/suggest.py new file mode 100644 index 0000000..1062965 --- /dev/null +++ b/app/engine/suggest.py @@ -0,0 +1,90 @@ +"""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