Files
matchmaking-v2/app/engine/stats/__init__.py
raulgupta be921559e7 Real Scout dashboard backend + per-board cursor rework
- get_scout_stats aggregator (app/engine/stats): assembles the REAL Summary metrics —
  funnel + cohort engagement rank + active-window (activity timestamps by hour) from our DB,
  accumulating salary band (avg of each deck's peak, ₹L), match/competition stats from the
  feed, Momentum/QX + Q-Score trend (qscore-service), day streak (user-service). Honest:
  unsourced cards return None so the UI omits/locks them, never faked. posted_date extractor.
- Activity tracking: viewed/saved flags + search_count → funnel (Matches→Viewed→Shortlisted→
  Applied) + engagement percentile. matchesFound = all-time count.
- Per-board search cursors {board: page} (replaces the single cursor): only boards that truly
  paginate (LinkedIn) get one; cursor = LAST page fetched (1st search of a new query → 1).
  Resets on query change OR >24h (boards refresh ~daily). Dropped Naukri incremental/stateKey
  (opaque, exhausting, cross-account dedup state) — dedup is the PER-USER seen-net only.
- tests: stats helpers (posting-age, histogram, active-window, engagement score).
2026-06-21 12:12:28 +05:30

115 lines
5.0 KiB
Python

"""Scout Summary dashboard — assemble REAL metrics (no fabricated numbers).
Honest sources (SCOUT_UI_SPEC):
- feed stats (matches / top / range / salary / competition) ← the persisted feed
- funnel + engagement rank ← opportunity_state activity (this DB)
- Apply window (freshness urgency) ← listing posting-age
- Momentum/QX + Readiness trend ← qscore-service (Phase 1)
- Day streak ← dashboard-service (Phase 1)
Anything without a real source is omitted — the UI cuts or locks it, never fakes it.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from app.db import repo as _repo
from app.engine.stats import clients as _clients
logger = logging.getLogger(__name__)
def _match_scores(opps: list[dict]) -> list[int]:
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
def _histogram(scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11) -> list[int]:
h = [0] * bins
span = (hi - lo) / bins
for s in scores:
i = min(bins - 1, max(0, int((s - lo) / span)))
h[i] += 1
return h
def _days_since(iso: str | None) -> int | None:
if not iso:
return None
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return max(0, (datetime.now(timezone.utc) - dt).days)
except (ValueError, TypeError):
return None
def _apply_window(opps: list[dict]) -> dict:
"""Freshness urgency from listing posting-age (replaces the old 'golden window'). Fresher
deck → apply now. Honest: derived from the real posting dates the actors returned."""
ages = [d for o in opps if (d := _days_since(o.get("posted_date"))) is not None]
if not ages:
return {"median_age_days": None, "fresh_share": None, "urgency": None}
ages.sort()
median = ages[len(ages) // 2]
fresh = sum(1 for a in ages if a <= 3) / len(ages) # posted within 3 days
urgency = "high" if median <= 3 else "medium" if median <= 7 else "low"
return {"median_age_days": median, "fresh_share": round(fresh, 2), "urgency": urgency, "dated": len(ages)}
async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> dict:
"""The real Summary payload. Feed + activity are wired now; qscore/streak land in Phase 1."""
# local DB reads + cross-service reads (QX, QX-trend, streak) — all concurrent, each degrades to None
feed, act, active, salary_band, qx, qx_trend, streak = await asyncio.gather(
_repo.get_feed(user_id),
_repo.get_activity_stats(user_id),
_repo.get_active_window(user_id),
_repo.get_salary_band(user_id),
_clients.fetch_qx(user_uuid),
_clients.fetch_qx_trend(user_uuid),
_clients.fetch_streak(user_id),
)
feed = feed or {}
opps = feed.get("opportunities") or []
scores = _match_scores(opps)
apps = [j.get("applicants") for j in opps if j.get("applicants")]
matches = len(opps)
return {
# ── feed-derived (real) ──
# all-time roles Scout has matched you with (consistent with the funnel's "Matches"), not just
# the latest deck — that's the headline "Matches found" number.
"matchesFound": max(act["matches"], matches),
"topMatch": max(scores) if scores else None,
"matchMin": min(scores) if scores else None,
"matchMax": max(scores) if scores else None,
"matchHist": _histogram(scores) if scores else [],
# salary band = average of each deck's peak salary, accumulated across decks (₹L)
"salaryMaxL": salary_band,
"avgApplicants": round(sum(apps) / len(apps)) if apps else None,
"activeWindow": active if active.get("samples") else None, # WHEN the user works their search
# ── activity-derived (real, this DB) — funnel is all-time so the stages are consistent ──
"funnel": [
{"label": "Matches", "value": max(act["matches"], matches), "tracked": True},
{"label": "Viewed", "value": act["viewed"], "tracked": True},
{"label": "Shortlisted", "value": act["saved"], "tracked": True},
{"label": "Applied", "value": act["applied"], "tracked": True},
],
"tailoredResumes": act["tailored"],
"searches": act["searches"],
"percentile": act["percentile"], # cohort engagement percentile (top 100-percentile %)
"cohortSize": act["cohort_size"],
"searchStage": "Applying" if act["applied"] else "Reviewing" if act["saved"] else "Scanning",
# ── cross-service (real; None → the UI omits the card, never fakes it) ──
"qxNow": (qx or {}).get("qx"),
"quotients": (qx or {}).get("quotients"),
"qxTrend": qx_trend, # Q-Score series (dashboard-service) → Readiness-trend sparkline
"dayStreak": streak,
}