115 lines
5.0 KiB
Python
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,
|
|
}
|