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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
90
app/engine/suggest.py
Normal 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
|
||||
Reference in New Issue
Block a user