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)
This commit is contained in:
raulgupta
2026-06-19 19:25:24 +05:30
parent b1e9cdd182
commit ecf5060acd
3 changed files with 102 additions and 0 deletions

View File

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

View File

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

90
app/engine/suggest.py Normal file
View File

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