diff --git a/app/engine/stats/__init__.py b/app/engine/stats/__init__.py index 5571dd8..82552da 100644 --- a/app/engine/stats/__init__.py +++ b/app/engine/stats/__init__.py @@ -9,9 +9,11 @@ Honest sources (SCOUT_UI_SPEC): Anything without a real source is omitted — the UI cuts or locks it, never fakes it. """ + from __future__ import annotations import asyncio +import math import logging from datetime import datetime, timezone @@ -30,7 +32,9 @@ def _match_scores(opps: list[dict]) -> list[int]: return out -def _histogram(scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11) -> list[int]: +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: @@ -57,9 +61,14 @@ def _apply_window(opps: list[dict]) -> dict: 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 + 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)} + 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: @@ -78,7 +87,17 @@ async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> di opps = feed.get("opportunities") or [] scores = _match_scores(opps) - apps = [j.get("applicants") for j in opps if j.get("applicants")] + apps = [] + for opportunity in opps: + raw_applicants = opportunity.get("applicants") + if isinstance(raw_applicants, bool): + continue + try: + applicants = float(raw_applicants) + except (TypeError, ValueError): + continue + if math.isfinite(applicants) and applicants >= 0: + apps.append(applicants) matches = len(opps) return { @@ -93,22 +112,34 @@ async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> di # 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 + "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": "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 %) + "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", + "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 + "qxTrend": qx_trend, # RQ Score series (qscore-service) → Readiness-trend sparkline "dayStreak": streak, } diff --git a/tests/test_stats.py b/tests/test_stats.py index 2ff8231..ea42719 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,4 +1,5 @@ """Dashboard stats — pure-helper and RQ Score client contract tests.""" + import asyncio import httpx @@ -10,31 +11,100 @@ from app.db.repo import _activity_score def test_posted_date_per_board(): - assert _posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z" # Naukri - assert _posted_date({"postedDate": "2026-06-18T12:46:39Z"}) == "2026-06-18T12:46:39Z" # LinkedIn - assert _posted_date({"date_posted": "2026-06-17T17:38:14Z"}) == "2026-06-17T17:38:14Z" # Foundit - assert _posted_date({"jobDetails": {"createdDate": "2026-06-18T00:00:00Z"}}) is not None # nested + assert ( + _posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z" + ) # Naukri + assert ( + _posted_date({"postedDate": "2026-06-18T12:46:39Z"}) == "2026-06-18T12:46:39Z" + ) # LinkedIn + assert ( + _posted_date({"date_posted": "2026-06-17T17:38:14Z"}) == "2026-06-17T17:38:14Z" + ) # Foundit + assert ( + _posted_date({"jobDetails": {"createdDate": "2026-06-18T00:00:00Z"}}) + is not None + ) # nested assert _posted_date({"nope": "x"}) is None def test_match_scores_and_histogram(): opps = [{"match": {"score": 84}}, {"matchScore": 72}, {"match": {"score": 0}}, {}] scores = S._match_scores(opps) - assert sorted(scores) == [72, 84] # 0 and missing dropped + assert sorted(scores) == [72, 84] # 0 and missing dropped h = S._histogram(scores) assert sum(h) == 2 and len(h) == 11 +def test_scout_stats_accepts_numeric_applicant_strings(monkeypatch): + feed = { + "opportunities": [ + {"applicants": "10"}, + {"applicants": 30}, + {"applicants": "unknown"}, + ] + } + activity = { + "matches": 3, + "viewed": 0, + "saved": 0, + "applied": 0, + "tailored": 0, + "searches": 1, + "percentile": None, + "cohort_size": 1, + } + monkeypatch.setattr( + S._repo, "get_feed", lambda _user_id: asyncio.sleep(0, result=feed) + ) + monkeypatch.setattr( + S._repo, + "get_activity_stats", + lambda _user_id: asyncio.sleep(0, result=activity), + ) + monkeypatch.setattr( + S._repo, "get_active_window", lambda _user_id: asyncio.sleep(0, result={}) + ) + monkeypatch.setattr( + S._repo, "get_salary_band", lambda _user_id: asyncio.sleep(0, result=None) + ) + monkeypatch.setattr( + S._clients, "fetch_qx", lambda _user_uuid: asyncio.sleep(0, result=None) + ) + monkeypatch.setattr( + S._clients, "fetch_qx_trend", lambda _user_uuid: asyncio.sleep(0, result=None) + ) + monkeypatch.setattr( + S._clients, "fetch_streak", lambda _user_id: asyncio.sleep(0, result=None) + ) + + result = asyncio.run(S.build_scout_stats("user-1")) + + assert result["avgApplicants"] == 20 + + def test_apply_window_freshness(): # all None when no posting dates (honest — never fabricated) assert S._apply_window([{}, {"posted_date": None}])["urgency"] is None - w = S._apply_window([{"posted_date": "2026-06-19T00:00:00Z"}, {"posted_date": "2026-06-15T00:00:00Z"}]) - assert w["dated"] == 2 and w["median_age_days"] is not None and w["urgency"] in ("high", "medium", "low") + w = S._apply_window( + [ + {"posted_date": "2026-06-19T00:00:00Z"}, + {"posted_date": "2026-06-15T00:00:00Z"}, + ] + ) + assert ( + w["dated"] == 2 + and w["median_age_days"] is not None + and w["urgency"] in ("high", "medium", "low") + ) def test_activity_score_weighting(): # applies weighted highest, then saves, views, searches - assert _activity_score(0, 0, 1, 0) > _activity_score(0, 1, 0, 0) > _activity_score(1, 0, 0, 0) + assert ( + _activity_score(0, 0, 1, 0) + > _activity_score(0, 1, 0, 0) + > _activity_score(1, 0, 0, 0) + ) assert _activity_score(0, 0, 0, 0) == 0 @@ -83,7 +153,9 @@ def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch): def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch): async def _get(_self, _url, **_kwargs): - return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]}) + return _Response( + {"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]} + ) monkeypatch.setattr(httpx.AsyncClient, "get", _get) @@ -92,13 +164,15 @@ def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch): def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch): async def _get(_self, _url, **_kwargs): - return _Response({ - "points": [ - {"rq_score": 35, "q_score": 91}, - {"rq_score": 44.16, "q_score": 92}, - {"q_score": 93}, - ] - }) + return _Response( + { + "points": [ + {"rq_score": 35, "q_score": 91}, + {"rq_score": 44.16, "q_score": 92}, + {"q_score": 93}, + ] + } + ) monkeypatch.setattr(httpx.AsyncClient, "get", _get)