Files
matchmaking-v2/app/agent/session.py
-Puter 98a4ec305e fix(events): normalize ingest URL, add source header, raise_for_status, dedupe keys
Grow event emitter fixes (wrong route / silent failure cannot recur):
- URL: /events/ingest -> /events/ingest/service (canonical service ingest path)
- Add x-growqr-source: matchmaking-service header
- Call resp.raise_for_status() so a 401 is caught and logged, not silently
  swallowed (401 is now observable; a wrong route surfaces immediately)
- Action-specific dedupe keys so retries collapse but later distinct user
  actions are NOT dropped:
    matches.generated  -> matchmaking:generated:{user}:{sig}
    feed.viewed        -> matchmaking:feed:{user}:{updated_at|none}
    match.viewed       -> matchmaking:viewed:{user}:{opp_id}
    match.applied      -> matchmaking:applied:{user}:{opp_id}  (already present)

All four emit handlers fire the event AFTER the persistent mutation
(set_opportunity_state / save_feed), so no event leaks when a mutation fails.

Tests (tests/test_grow_events.py, 12 cases, red->green TDD):
- URL normalizes to /events/ingest/service (with/without trailing slash)
- x-growqr-source header present
- 401 response raises_for_status -> logged warning; does not escape _post
- emit-after-mutation ordering; no emit when mutation raises
- dedupe keys stable for same evidence, distinct for different evidence
  across generated/feed/viewed/applied event types
2026-07-10 20:02:27 +05:30

377 lines
21 KiB
Python

"""Matchmaking-v2 agent session.
Mirrors the mesh contract: the orchestrator calls /a2a/tasks, which drives
on_session_start / on_user_action; handlers `push` messages back (agent_thinking,
agent_data{action,data}, agent_error) that the orchestrator forwards to the frontend.
Step-0 scaffold: the dispatch + contract are wired; handlers are honest stubs that
say "not implemented yet" so we can fill them in one full-stack slice at a time.
"""
from __future__ import annotations
import asyncio
import logging
from app.grow_events import emit_grow_event
logger = logging.getLogger(__name__)
class MatchmakingAgentSession:
def __init__(self, websocket, user_id: str):
self.ws = websocket
self.user_id = user_id
self.context: dict = {}
async def _grow_activity_fields(self) -> dict:
"""Cheap cumulative activity context shared across Grow payloads — the funnel counts the
backend extractor maps to qscore signals (applications_submitted, opportunities_viewed).
Degrades to empty dict if the DB is off."""
from app.db.repo import get_activity_stats
try:
act = await get_activity_stats(self.user_id)
return {
"applications_submitted": act.get("applied", 0), # ← projector reads applications_submitted
"opportunities_viewed": act.get("viewed", 0),
"jobs_viewed": act.get("viewed", 0), # ← projector reads jobs_viewed
"opportunities_saved": act.get("saved", 0),
"searches": act.get("searches", 0),
"matches_generated": act.get("matches", 0),
"matchCount": act.get("matches", 0), # ← projector reads matchCount
}
except Exception: # noqa: BLE001 — never fatal
return {}
@staticmethod
def _match_scores(opps: list[dict]) -> list[int]:
"""Extract integer match scores from opportunities for source score fields."""
out = []
for o in opps:
s = (o.get("match") or {}).get("score") or o.get("matchScore")
if isinstance(s, (int, float)) and s > 0:
out.append(int(s))
return out
async def push(self, msg_type: str, **kwargs):
await self.ws.send_json({"type": msg_type, **kwargs})
async def _with_heartbeat(self, awaitable, messages: list[str], interval: float = 5.0):
"""Await `awaitable` while emitting a rotating progress message every `interval`s.
A live search runs ~30-40s (Apify scrape + Opus curation). Without a steady trickle of
messages, the Redis pub/sub channel that relays the result back to the browser idles out and
the final `search_complete` is dropped — which is why users were seeing 0 matches. The
heartbeat keeps that channel warm AND gives the user real progress during the wait.
"""
task = asyncio.ensure_future(awaitable)
i = 0
while True:
done, _ = await asyncio.wait({task}, timeout=interval)
if task in done:
return task.result()
await self.push("agent_thinking", message=messages[i % len(messages)])
i += 1
async def _stub(self, action: str, note: str):
await self.push("agent_data", action=action, data={"status": "not_implemented", "note": note})
async def on_session_start(self, params: dict):
self.context["user_context"] = params.get("user_context")
await self.push("agent_thinking", message="Scout is warming up…")
# Real flow lands in a later slice: resolve prefs → run_search → emit feed.
await self.push("agent_data", action="session_ready", data={"service": "matchmaking-v2", "version": "2.0.0"})
async def on_user_action(self, action: str, params: dict):
handlers = {
# existing contract
"sync_preferences": self.handle_sync_preferences,
"get_feed": self.handle_get_feed,
"get_scout_feed": self.handle_get_feed, # unique name (avoids the course-service "get_feed" collision)
"record_feedback": self.handle_record_feedback,
"get_opportunity_detail": self.handle_get_opportunity_detail,
# new in v2
"generate_matches": self.handle_run_search,
"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,
# per-opportunity state — persists across deck refreshes (applied / dismissed / saved docs)
"dismiss_opportunity": self.handle_dismiss_opportunity,
"restore_opportunity": self.handle_restore_opportunity,
"mark_applied": self.handle_mark_applied,
"save_apply_docs": self.handle_save_apply_docs,
# activity tracking → the dashboard funnel + engagement rank
"mark_viewed": self.handle_mark_viewed,
"mark_saved": self.handle_mark_saved,
"get_scout_stats": self.handle_get_scout_stats,
}
handler = handlers.get(action)
if not handler:
await self.push("agent_error", message=f"Unknown action: {action}")
return
try:
await handler(params)
except Exception as e: # noqa: BLE001 — surface to the client, log the trace
logger.error("action '%s' failed: %s", action, e, exc_info=True)
await self.push("agent_error", message=f"Failed to {action}: {e}")
# ── handlers (stubs — replaced one full-stack slice at a time) ──
async def handle_sync_preferences(self, params: dict):
await self._stub("preferences_synced", "persist ScoutPrefs → label store")
async def handle_get_feed(self, params: dict):
"""Replay the user's last persisted feed so matches survive navigation (no re-search)."""
from app.db.repo import get_feed
feed = await get_feed(self.user_id)
await self.push("agent_data", action="feed_loaded", data=feed or {"opportunities": [], "cached": False})
feed_opps = (feed or {}).get("opportunities") or []
feed_scores = self._match_scores(feed_opps)
await emit_grow_event(
"matchmaking.feed.viewed",
self.user_id,
payload={
"action": "get_feed",
"match_count": len(feed_opps),
"cached": bool((feed or {}).get("cached", True)),
"source_scores": feed_scores,
"matchMin": min(feed_scores) if feed_scores else None,
"matchMax": max(feed_scores) if feed_scores else None,
"match_min": min(feed_scores) if feed_scores else None,
"match_max": max(feed_scores) if feed_scores else None,
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": "feed"},
dedupe_key=f"matchmaking:feed:{self.user_id}:{(feed or {}).get('updated_at') or 'none'}",
)
async def handle_record_feedback(self, params: dict):
await self._stub("feedback_recorded", "store SAVE/DISMISS/APPLY label")
async def handle_get_opportunity_detail(self, params: dict):
await self._stub("opportunity_detail", "return match-score breakdown")
async def handle_run_search(self, params: dict):
"""ScoutPrefs → warm-pool gate (serve banked jobs; sweep Apify only when the pool dips) →
sift → Opus curate → cards."""
from app.engine import search
from app.config import get_settings
prefs = params # the frontend's ScoutPrefs arrive as params (user_context, if present, is ignored)
fresh = bool(prefs.get("_fresh")) # dev toggle: force a live sweep instead of serving the pool
if not search.has_mvq(prefs):
await self.push("agent_data", action="search_complete",
data={"opportunities": [], "needs": ["title", "location"]})
return
from app.db import repo as _repo
s = get_settings()
sig = _repo.search_signature(prefs)
seen = await _repo.get_seen_ids(self.user_id)
# ── Warm-pool gate ──────────────────────────────────────────────────────────────────────────
# Bank surplus fetched jobs per (user, query); serve from the pool and re-pay Apify only when the
# fresh-unseen count dips below the floor. Reorder EARLY (background top-up) so it never runs thin.
pooled = await _repo.pool_load_fresh_unseen(self.user_id, sig, seen) if s.POOL_ENABLED else []
exhausted = await _repo.is_pool_exhausted(self.user_id, sig) if s.POOL_ENABLED else False
decision = search.pool_decision(len(pooled), fresh, s.POOL_SAFETY_FLOOR, s.POOL_REORDER_AT, exhausted)
if decision == "sweep":
# Pool below the hard floor (or forced): blocking sweep → bank ALL of it. No cursors — every
# fetch pulls the freshest page; the seen-net dedups + daily board refresh brings new jobs.
await self.push("agent_thinking",
message="Fetching fresh roles across job boards…" if fresh
else "Scanning live roles across job boards…")
sweep = await self._with_heartbeat(
search.run_sweep(prefs, fresh=fresh),
["Scanning live roles across the boards…", "Pulling the latest Naukri + LinkedIn + Foundit roles…",
"Gathering fresh postings for you…"])
new_count = await _repo.pool_save(self.user_id, sig, sweep["opportunities"])
# tapped out? a sweep that added almost nothing new → flag it so we stop block-fetching dupes.
if new_count < s.POOL_MIN_NEW_PER_REFILL:
await _repo.mark_pool_exhausted(self.user_id, sig)
pooled = await _repo.pool_load_fresh_unseen(self.user_id, sig, seen)
sources = sweep["sources"]
else:
# Pool healthy → serve from the shelf, zero Apify. (Cursors unchanged; refill, if any, is bg.)
await self.push("agent_thinking", message="Pulling your latest matches…")
sources = {"pool": len(pooled)}
candidates = pooled
# Engine: cheap rankers SIFT the pool → top ~22 (white-box ‖ embedding vibe); then Opus reads only
# those and curates the honest shortlist + report cards.
from app.engine import curate as _curate
from app.engine import rank as _rank
from app.engine import sift as _sift
user_context = params.get("user_context") # resume skills / experience / education / QScore
await self.push("agent_thinking", message="Scoring roles against your profile…")
# Run the blocking LLM work (embeddings + Opus) OFF the event loop, or the long sync OpenAI
# call freezes the loop and the Redis response can't publish (→ the loader hangs forever).
top, dbg = await asyncio.to_thread(_sift.sift, prefs, user_context, candidates)
# Heartbeat through the long Opus curation (the other 30-40s gap that was dropping responses).
curated = await self._with_heartbeat(
asyncio.to_thread(_curate.curate, prefs, user_context, top), # Opus's call is final
["Scout is reading your top roles…", "Scoring fit + writing your report cards…",
"Ranking your strongest matches…", "Almost there…"])
if curated is None: # safety net: sift + templated cards
curated = _rank.select(top, prefs.get("stretch", "balanced"))
engine = f"fallback:{dbg['mode']}"
else:
engine = "opus"
result = {"opportunities": curated, "sources": sources,
"shortlisted": len(curated), "scanned": dbg["scored"], "engine": engine,
"has_profile": bool(user_context and user_context.get("skills"))}
# Persist the deck (no cursors — pagination removed; freshness is date-sort + the seen-net).
clean_prefs = {k: v for k, v in prefs.items()
if k not in ("user_context", "_fresh", "_cursors")}
await _repo.save_feed(self.user_id, clean_prefs, result, query_sig=sig)
# Accumulate the salary band — each deck contributes its peak salary, averaged over decks
# (survives decks that disclose nothing, like Naukri "Not disclosed").
await _repo.update_salary_band(self.user_id, [o.get("salary_lpa") for o in (result["opportunities"] or [])])
# Mark the shown deck as SEEN so it never reappears in a future run.
await _repo.mark_seen(self.user_id, [o.get("id") for o in (result["opportunities"] or []) if o.get("id")])
# Hand the UI a state-aware view: dismissed dropped, applied + saved-doc ids annotated.
states = await _repo.get_opportunity_states(self.user_id)
result["opportunities"] = _repo.annotate_and_filter(result["opportunities"], states)
result["shortlisted"] = len(result["opportunities"])
await self.push("agent_data", action="search_complete", data=result)
# Grow event: matches were generated for this user → backend extracts qscore signals
gen_opps = result["opportunities"] or []
gen_scores = self._match_scores(gen_opps)
await emit_grow_event(
"matchmaking.matches.generated",
self.user_id,
payload={
"action": "run_search",
"matchCount": len(gen_opps), # ← projector reads matchCount/matches/shortlisted
"match_count": len(gen_opps), # snake_case alias
"scanned": result.get("scanned"),
"total_candidates": result.get("scanned"), # ← projector reads total_candidates for match_rate
"shortlisted": result.get("shortlisted"),
"sources": result.get("sources"),
"engine": result.get("engine"),
"source_scores": gen_scores,
"matchMin": min(gen_scores) if gen_scores else None, # ← scout_stats camelCase
"matchMax": max(gen_scores) if gen_scores else None,
"match_min": min(gen_scores) if gen_scores else None, # snake_case alias
"match_max": max(gen_scores) if gen_scores else None,
"top_match_score": max(gen_scores) if gen_scores else None,
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": sig},
correlation={"taskId": sig},
dedupe_key=f"matchmaking:generated:{self.user_id}:{sig}",
)
# The user already has their deck — if the pool dipped below the reorder mark, top it up OUT OF
# BAND so the next search stays free and the shelf never runs thin. (No await: fire-and-forget.)
if decision == "pool+refill":
asyncio.create_task(self._background_refill(clean_prefs, sig))
async def _background_refill(self, prefs: dict, sig: str):
"""Proactive pool top-up: re-fetch the freshest jobs in the background + bank the new ones, so the
fresh-unseen count climbs back above the reorder mark before the user's next search. No cursors —
the seen-net dedups and daily board refresh supplies new jobs. Best-effort, non-fatal."""
from app.engine import search
from app.db import repo as _repo
try:
from app.config import get_settings
sweep = await search.run_sweep(prefs, fresh=True)
new_count = await _repo.pool_save(self.user_id, sig, sweep["opportunities"])
if new_count < get_settings().POOL_MIN_NEW_PER_REFILL: # tapped out → relax the floor
await _repo.mark_pool_exhausted(self.user_id, sig)
logger.info("pool refill (bg): +%d NEW jobs, sig=%s", new_count, sig[:8])
except Exception as e: # noqa: BLE001
logger.warning("background refill failed (non-fatal): %s", e)
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")
async def handle_submit_application(self, params: dict):
await self._stub("application_submitted", "master-key auto-apply (tier-aware)")
async def handle_get_apply_proof(self, params: dict):
await self._stub("apply_proof", "return captured confirmation screenshots")
# ── per-opportunity state (persists across deck refreshes) ──
async def handle_dismiss_opportunity(self, params: dict):
"""Mark a posting stale/dismissed → it never resurfaces in the deck."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="dismissed")
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": "dismissed"})
async def handle_restore_opportunity(self, params: dict):
"""Undo a dismiss/pass → clear the status so the role returns to the deck."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status=None)
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": None})
async def handle_mark_applied(self, params: dict):
"""Record that the user applied to a role (sticks across reloads)."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="applied")
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": "applied"})
await emit_grow_event(
"matchmaking.match.applied",
self.user_id,
payload={
"opportunity_id": params.get("opportunity_id"),
"status": "applied",
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": params.get("opportunity_id", "")},
dedupe_key=f"matchmaking:applied:{self.user_id}:{params.get('opportunity_id', '')}",
)
async def handle_save_apply_docs(self, params: dict):
"""Remember the resume-builder document ids generated for a role so we never re-pay to
re-tailor / re-generate them. Only the provided ids are written."""
from app.db.repo import set_opportunity_state
fields = {k: params[k] for k in ("tailored_resume_id", "tailored_version_id", "cover_letter_id")
if params.get(k)}
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), **fields)
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), **fields})
# ── activity tracking (dashboard funnel + engagement rank) ──
async def handle_mark_viewed(self, params: dict):
"""The user opened a job → funnel 'Viewed'. Fire-and-forget (no response needed)."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), viewed=True)
await emit_grow_event(
"matchmaking.match.viewed",
self.user_id,
payload={
"opportunity_id": params.get("opportunity_id"),
"action": "viewed",
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": params.get("opportunity_id", "")},
dedupe_key=f"matchmaking:viewed:{self.user_id}:{params.get('opportunity_id', '')}",
)
async def handle_mark_saved(self, params: dict):
"""The user saved/swiped-right a job → funnel 'Shortlisted'."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), saved=True)
async def handle_get_scout_stats(self, params: dict):
"""Assemble the REAL Summary dashboard metrics (Phase 2 fills the cross-service bits)."""
from app.engine import stats
data = await stats.build_scout_stats(
self.user_id,
user_uuid=params.get("user_uuid"),
)
await self.push("agent_data", action="scout_stats", data=data)