Files
matchmaking-v2/app/engine/stats/__init__.py
2026-07-14 15:41:48 +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/RQ Score + 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, # RQ Score series (qscore-service) → Readiness-trend sparkline
"dayStreak": streak,
}