"""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, }